ompchamber 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +279 -280
  3. package/dist/client/static/js/index-01dv383b.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/cli/lib/output.js +15 -8
  7. package/src/client/components/common/Modal.tsx +14 -4
  8. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  10. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  11. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  12. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  13. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  15. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  16. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  17. package/src/client/components/settings/Modal.tsx +35 -6
  18. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  19. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  20. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  21. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  22. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  23. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  24. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  25. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  26. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  27. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  28. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  29. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  30. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  31. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  32. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  33. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  35. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/master-detail/index.tsx +68 -0
  38. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  41. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  50. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  51. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  52. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  53. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  54. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  55. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  56. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  57. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  58. package/src/client/hooks/chat/subagent/index.ts +37 -11
  59. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  60. package/src/client/hooks/settings/master-detail.ts +33 -0
  61. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  62. package/src/server/lib/db/client.ts +5 -1
  63. package/src/server/lib/fs/git-diff.ts +34 -10
  64. package/src/server/lib/fs/git-repos.ts +81 -0
  65. package/src/server/lib/lifecycle/flock.ts +199 -0
  66. package/src/server/lib/lifecycle/identity.ts +5 -1
  67. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  68. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  69. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  70. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  71. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  72. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  73. package/src/server/lib/observer-ws.ts +150 -0
  74. package/src/server/lib/omp/rpc/constants.ts +9 -0
  75. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  76. package/src/server/lib/omp/session/file-scan.ts +7 -1
  77. package/src/server/lib/omp/session/files.ts +95 -96
  78. package/src/server/lib/omp/session/locator.ts +18 -8
  79. package/src/server/lib/omp/session/messages.ts +7 -7
  80. package/src/server/lib/omp/session/scan.ts +108 -0
  81. package/src/server/lib/omp/session/stats.ts +50 -50
  82. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  83. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  84. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  85. package/src/server/lib/terminal/runtime.server.ts +3 -0
  86. package/src/server/lib/terminal/session.server.ts +30 -3
  87. package/src/server/routes/agent/command.test.ts +69 -0
  88. package/src/server/routes/agent/command.ts +14 -0
  89. package/src/server/routes/agent/ws.test.ts +159 -0
  90. package/src/server/routes/agent/ws.ts +22 -97
  91. package/src/server/routes/btw/ws.ts +22 -95
  92. package/src/server/routes/fs/git.ts +61 -70
  93. package/src/server/routes/omp/session-stats.ts +1 -1
  94. package/src/server/routes/telemetry/tokens.ts +6 -4
  95. package/src/server/routes/terminal/run.ts +4 -8
  96. package/src/server/routes/terminal/ws.ts +24 -2
  97. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  98. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  99. package/src/shared/lib/code/language.ts +146 -0
  100. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  101. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  102. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  103. package/src/shared/lib/fs/diff-parser.ts +67 -42
  104. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  105. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  106. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  107. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  108. package/src/shared/types/omp/subagent.ts +6 -0
  109. package/dist/client/static/js/index-5yfre1kw.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.1.1",
3
+ "version": "3.2.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>",
@@ -95,6 +95,12 @@ export function fail(message, code = 1) {
95
95
 
96
96
  /**
97
97
  * Render a simple padded plain-text table for human output.
98
+ *
99
+ * Widths are measured with `Bun.stringWidth`, not `String.length`: `.length`
100
+ * counts UTF-16 code units, so a CJK session title or an emoji reported a width
101
+ * it does not render at and the whole table misaligned (verified: a 5-column
102
+ * overcount on a Japanese title). `stringWidth` also ignores ANSI escapes, so a
103
+ * coloured cell does not pad against its own escape bytes.
98
104
  */
99
105
  export function formatTable(rows, headers = []) {
100
106
  const body = Array.isArray(rows) ? rows.map((row) => (Array.isArray(row) ? row : [row])) : [];
@@ -105,21 +111,22 @@ export function formatTable(rows, headers = []) {
105
111
  return '';
106
112
  }
107
113
 
114
+ const cellText = (row, i) => (row[i] === undefined || row[i] === null ? '' : String(row[i]));
108
115
  const widths = [];
109
116
  for (let i = 0; i < columns; i += 1) {
110
- const headerWidth = head[i] === undefined ? 0 : String(head[i]).length;
111
- const cellWidth = body.reduce((max, row) => {
112
- const cell = row[i] === undefined || row[i] === null ? '' : String(row[i]);
113
- return Math.max(max, cell.length);
114
- }, 0);
115
- widths[i] = Math.max(headerWidth, cellWidth);
117
+ let width = head[i] === undefined ? 0 : Bun.stringWidth(String(head[i]));
118
+ for (const row of body) {
119
+ const cellWidth = Bun.stringWidth(cellText(row, i));
120
+ if (cellWidth > width) width = cellWidth;
121
+ }
122
+ widths[i] = width;
116
123
  }
117
124
 
118
125
  const renderRow = (row) =>
119
126
  widths
120
127
  .map((width, i) => {
121
- const cell = row[i] === undefined || row[i] === null ? '' : String(row[i]);
122
- return cell.padEnd(width);
128
+ const cell = cellText(row, i);
129
+ return cell + ' '.repeat(Math.max(0, width - Bun.stringWidth(cell)));
123
130
  })
124
131
  .join(' ')
125
132
  .trimEnd();
@@ -45,13 +45,19 @@ export function Modal({
45
45
  return (
46
46
  <div
47
47
  className={`fixed inset-0 bg-ink/40 backdrop-blur-[2px] ${zClass} flex items-center justify-center p-4`}
48
+ style={{
49
+ paddingTop: 'max(1rem, env(safe-area-inset-top, 0px))',
50
+ paddingBottom: 'max(1rem, env(safe-area-inset-bottom, 0px))',
51
+ paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))',
52
+ paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))',
53
+ }}
48
54
  onClick={onClose}
49
55
  >
50
56
  <div
51
- className={`bg-paper border border-ink/15 rounded-xl shadow-2xl w-full ${maxWidthClass} overflow-hidden text-ink`}
57
+ className={`bg-paper border border-ink/15 rounded-xl shadow-2xl w-full ${maxWidthClass} max-h-full flex flex-col overflow-hidden text-ink`}
52
58
  onClick={(e) => e.stopPropagation()}
53
59
  >
54
- <div className="px-5 py-4 border-b border-ink/10 flex items-center justify-between">
60
+ <div className="px-5 py-4 border-b border-ink/10 flex items-center justify-between flex-shrink-0">
55
61
  {header}
56
62
  <button
57
63
  type="button"
@@ -63,11 +69,15 @@ export function Modal({
63
69
  </div>
64
70
 
65
71
  {form ? (
66
- <form onSubmit={form.onSubmit} className={form.className}>
72
+ // The body scrolls, not the panel: a long form (the add-provider
73
+ // dialog is 832px of fields) is taller than a phone viewport, and
74
+ // without a bounded, scrollable body its footer sat below the screen
75
+ // with no way to reach it — the dialog could not be submitted.
76
+ <form onSubmit={form.onSubmit} className={`${form.className} min-h-0 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static`}>
67
77
  {body}
68
78
  </form>
69
79
  ) : (
70
- body
80
+ <div className="min-h-0 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static">{body}</div>
71
81
  )}
72
82
  </div>
73
83
  </div>
@@ -4,7 +4,7 @@ import { SubagentStatusIcon } from '@/client/components/common/SubagentStatusIco
4
4
  import { isRecord } from '@/shared/lib/util/guards';
5
5
  import { fetchSubagentHistory, historyEntryToSubagentInfo } from '@/shared/lib/omp/subagent/history/client';
6
6
  import { subagentRowLabel } from '@/shared/lib/omp/subagent/label';
7
- import { mergeSubagentRoster, parseSubagentLifecycle, parseSubagentProgress, parseSubagentRosterResponse } from '@/shared/lib/omp/subagent/parse';
7
+ import { mergeSubagentRoster, parseSubagentLifecycle, parseSubagentRosterResponse, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
8
8
  import type { SubagentInfo, SubagentProgress } from '@/shared/types';
9
9
 
10
10
  type SubagentListProps = {
@@ -18,14 +18,6 @@ const PROGRESS_STATUS: Record<NonNullable<SubagentProgress['status']>, SubagentI
18
18
  pending: 'started', running: 'started', completed: 'completed', failed: 'failed', aborted: 'aborted',
19
19
  };
20
20
 
21
- /** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
22
- function readProgress(payload: unknown): SubagentProgress | undefined {
23
- const direct = parseSubagentProgress(payload);
24
- if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
25
- const nested = parseSubagentProgress(payload.progress);
26
- return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
27
- }
28
-
29
21
  /** Fold one progress frame into the roster entry it names (id, else index). */
30
22
  function applyProgress(roster: SubagentInfo[], progress: SubagentProgress): SubagentInfo[] {
31
23
  return roster.map((entry) => {
@@ -44,6 +36,8 @@ function applyProgress(roster: SubagentInfo[], progress: SubagentProgress): Suba
44
36
  /**
45
37
  * Subagent roster nested under a session row: renders clean, readable subagent
46
38
  * items matching the minimalist sidebar layout while preserving interactive inspection.
39
+ * Shared by the desktop sidebar's `CategoryItem` and the mobile drawer's
40
+ * `MobileSessionCategory`, so both list the same roster.
47
41
  */
48
42
  export function SubagentList({ sessionId, isActiveSession }: SubagentListProps) {
49
43
  const [searchParams] = useSearchParams();
@@ -85,13 +79,17 @@ export function SubagentList({ sessionId, isActiveSession }: SubagentListProps)
85
79
  }
86
80
  };
87
81
  const onProgress = (event: Event) => {
88
- const progress = readProgress(detailOf(event)?.payload);
82
+ const progress = readSubagentProgressFrame(detailOf(event)?.payload);
89
83
  if (progress) setSubagents((prev) => applyProgress(prev, progress));
90
84
  };
91
85
 
92
86
  window.addEventListener('subagent_lifecycle', onLifecycle);
93
87
  window.addEventListener('subagent_progress', onProgress);
94
88
 
89
+ // Snapshot the LIVE registry. This POST is the spawn path, so the server
90
+ // refuses it (409) for a session it does not manage — an observer read must
91
+ // never boot an omp child. The history pass above already covers a finished
92
+ // session from disk, so nothing is missing when this comes back empty.
95
93
  fetch(`/api/agent/${encodeURIComponent(sid)}`, {
96
94
  method: 'POST',
97
95
  headers: { 'Content-Type': 'application/json' },
@@ -6,9 +6,9 @@ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
6
6
  import { useShowMore } from '@/client/hooks/ui/show-more';
7
7
  import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
8
8
  import { SessionItem } from '@/client/components/layout/session-sidebar/SessionItem';
9
- import { SubagentList } from '@/client/components/layout/session-sidebar/SubagentList';
9
+ import { SubagentList } from '@/client/components/common/subagent-list';
10
10
  import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
11
- import { loadExpandedSessionIds, saveExpandedSessionIds } from '@/shared/lib/workspace/sidebar-expanded';
11
+ import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
12
12
  import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
13
13
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
14
14
 
@@ -37,32 +37,14 @@ export function Category({
37
37
  const [searchParams] = useSearchParams();
38
38
  const urlSubagentId = searchParams.get('subagent');
39
39
  const urlSessionId = searchParams.get('sessionId');
40
-
41
- // Sidebar-level expanded session set
42
- const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(() => new Set());
43
40
 
44
- useEffect(() => {
45
- const saved = loadExpandedSessionIds();
46
- if (saved && saved.size > 0) {
47
- setExpandedSessionIds(saved);
48
- }
49
- }, []);
41
+ // Roster expansion is shared with the mobile drawer: one persisted set, so a
42
+ // row expanded on the phone is expanded here too.
43
+ const { expandedSessionIds, toggleSession } = useExpandedSessions();
50
44
 
51
45
  useEffect(() => {
52
46
  if (typeof folder.isExpanded === 'boolean') setIsOpen(folder.isExpanded);
53
47
  }, [folder.isExpanded]);
54
-
55
- // A deep-linked transcript (?sessionId=…&subagent=…) auto-expands its
56
- // session row so the viewed roster entry is visible after a reload.
57
- useEffect(() => {
58
- if (!urlSubagentId || !urlSessionId) return;
59
- setExpandedSessionIds(prev => {
60
- if (prev.has(urlSessionId)) return prev;
61
- const next = new Set(prev);
62
- next.add(urlSessionId);
63
- return next;
64
- });
65
- }, [urlSubagentId, urlSessionId]);
66
48
 
67
49
  const menuRef = useRef<HTMLDivElement>(null);
68
50
  const { refresh } = useSidebarData();
@@ -106,13 +88,7 @@ export function Category({
106
88
  };
107
89
 
108
90
  const handleToggleSessionExpand = (sessionId: string) => {
109
- setExpandedSessionIds((prev) => {
110
- const next = new Set(prev);
111
- if (next.has(sessionId)) next.delete(sessionId);
112
- else next.add(sessionId);
113
- saveExpandedSessionIds(next);
114
- return next;
115
- });
91
+ toggleSession(sessionId);
116
92
  };
117
93
 
118
94
  const onPin = () => {
@@ -18,6 +18,8 @@ interface SessionSidebarToolbarProps {
18
18
  onNewWorkspace: () => void;
19
19
  onScheduler: () => void;
20
20
  onRefresh: () => void;
21
+ /** True while the user-initiated refresh is in flight — spins the icon. */
22
+ refreshing: boolean;
21
23
  onClose?: () => void;
22
24
  }
23
25
 
@@ -37,6 +39,7 @@ export function SessionSidebarToolbar({
37
39
  onNewWorkspace,
38
40
  onScheduler,
39
41
  onRefresh,
42
+ refreshing,
40
43
  onClose,
41
44
  }: SessionSidebarToolbarProps) {
42
45
  return (
@@ -45,16 +48,19 @@ export function SessionSidebarToolbar({
45
48
  <div className="flex space-x-3 items-center">
46
49
  <FolderPlus size={14} className="hover:text-ink cursor-pointer" onClick={onNewWorkspace} />
47
50
  <Calendar size={14} className="hover:text-ink cursor-pointer" onClick={onScheduler} />
48
- <span
49
- title="Refresh sessions"
51
+ <button
52
+ type="button"
53
+ title="Refresh sessions"
50
54
  onClick={onRefresh}
51
- className="inline-flex cursor-pointer"
55
+ className="inline-flex cursor-pointer bg-transparent border-0 p-0 text-ink/60"
56
+ aria-label="Refresh sessions"
57
+ aria-busy={refreshing}
52
58
  >
53
59
  <RefreshCw
54
60
  size={14}
55
- className="hover:text-ink"
61
+ className={`hover:text-ink ${refreshing ? 'animate-spin' : ''}`}
56
62
  />
57
- </span>
63
+ </button>
58
64
  </div>
59
65
  <div className="flex space-x-3 items-center relative">
60
66
  <Search
@@ -17,7 +17,8 @@ export function SessionSidebar({ className = '', onClose, appSettings = {} }: {
17
17
  const {
18
18
  folders,
19
19
  initializing,
20
- refresh,
20
+ refreshNow,
21
+ refreshing,
21
22
  activeSessionId,
22
23
  sessionParam,
23
24
  searchQuery,
@@ -105,7 +106,8 @@ export function SessionSidebar({ className = '', onClose, appSettings = {} }: {
105
106
  onSortChange={handleSortChange}
106
107
  onNewWorkspace={() => setNewWorkspaceOpen(true)}
107
108
  onScheduler={() => setSchedulerOpen(true)}
108
- onRefresh={refresh}
109
+ onRefresh={refreshNow}
110
+ refreshing={refreshing}
109
111
  onClose={onClose}
110
112
  />
111
113
 
@@ -74,6 +74,12 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
74
74
  });
75
75
  });
76
76
 
77
+ // A roster row opens a subagent transcript in the chat timeline (the
78
+ // `omp:view-subagent` listener there owns the URL). On the phone the chat is
79
+ // a separate screen, so the tap must also bring it forward — otherwise the
80
+ // transcript opens behind the drawer and the tap reads as a no-op.
81
+ useChamberEvent('omp:view-subagent', () => setCurrentScreen('main'));
82
+
77
83
  // Settings requests (e.g. the model dropdown asking for Settings → Providers
78
84
  // when no provider is configured) arrive on the same global channel the
79
85
  // desktop layout listens on. SettingsModal itself only reads the payload and
@@ -36,6 +36,8 @@ export function MobileSessionSidebar({
36
36
  const {
37
37
  folders,
38
38
  initializing,
39
+ refreshNow,
40
+ refreshing,
39
41
  searchQuery,
40
42
  setSearchQuery,
41
43
  showArchived,
@@ -124,6 +126,8 @@ export function MobileSessionSidebar({
124
126
  onNewSession={handleNewSessionAndClose}
125
127
  onNewWorkspace={() => setNewWorkspaceOpen(true)}
126
128
  onScheduler={() => setSchedulerOpen(true)}
129
+ onRefresh={refreshNow}
130
+ refreshing={refreshing}
127
131
  onToggleOptions={() => setOptionsOpen(!optionsOpen)}
128
132
  onSortChange={handleSortChange}
129
133
  onResetSort={() => handleSortChange('A-Z')}
@@ -1,8 +1,11 @@
1
1
  import { useRef, useState } from 'preact/hooks';
2
2
  import { ChevronDown, ChevronRight, GitBranch, MessageSquare, MoreHorizontal, Plus } from 'lucide-preact';
3
+ import { useSearchParams } from '@/client/lib/router/search-params';
3
4
  import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
5
+ import { SubagentList } from '@/client/components/common/subagent-list';
4
6
  import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
5
7
  import { useShowMore } from '@/client/hooks/ui/show-more';
8
+ import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
6
9
  import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
7
10
  import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
8
11
  import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
@@ -33,6 +36,12 @@ export function MobileSessionCategory({
33
36
  }: MobileSessionCategoryProps) {
34
37
  const { visibleCount, showMore } = useShowMore();
35
38
  const { refresh } = useSidebarData();
39
+ const { expandedSessionIds, toggleSession } = useExpandedSessions();
40
+ // The viewed transcript, so its session row dims while a roster entry is
41
+ // open (desktop parity — same params, same rule).
42
+ const [searchParams] = useSearchParams();
43
+ const urlSubagentId = searchParams.get('subagent');
44
+ const urlSessionId = searchParams.get('sessionId');
36
45
  const [showMenu, setShowMenu] = useState(false);
37
46
  const menuRef = useRef<HTMLDivElement>(null);
38
47
  const {
@@ -72,25 +81,32 @@ export function MobileSessionCategory({
72
81
  <div className="mb-4">
73
82
  <div
74
83
  onClick={onToggleExpand}
75
- className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
84
+ className="flex items-center justify-between pl-4 pr-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
76
85
  >
77
86
  <div className="flex items-center space-x-2 min-w-0">
78
- {folder.customIconUrl ? (
79
- <img
80
- src={folder.customIconUrl}
81
- alt=""
82
- className="w-[15px] h-[15px] rounded-xs object-contain flex-shrink-0"
83
- referrerPolicy="no-referrer"
84
- />
85
- ) : folder.iconType === 'chat' ? (
86
- <MessageSquare size={15} className="text-ink/80 flex-shrink-0" />
87
- ) : (
88
- <ProjectIcon
89
- size={15}
90
- className="text-ink/80 flex-shrink-0"
91
- style={{ color: folder.accentColor || undefined }}
92
- />
93
- )}
87
+ {/* The folder glyph occupies the same 16px left slot the session rows
88
+ give their roster toggle, so the two columns line up down the
89
+ drawer — the icon, the chevron and every row's status glyph share
90
+ one x. Desktop gets this from one shared slot; the drawer's
91
+ padding is what differs, hence the matching `pl-4`. */}
92
+ <span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
93
+ {folder.customIconUrl ? (
94
+ <img
95
+ src={folder.customIconUrl}
96
+ alt=""
97
+ className="w-[15px] h-[15px] rounded-xs object-contain"
98
+ referrerPolicy="no-referrer"
99
+ />
100
+ ) : folder.iconType === 'chat' ? (
101
+ <MessageSquare size={15} className="text-ink/80" />
102
+ ) : (
103
+ <ProjectIcon
104
+ size={15}
105
+ className="text-ink/80"
106
+ style={{ color: folder.accentColor || undefined }}
107
+ />
108
+ )}
109
+ </span>
94
110
 
95
111
  <span className="text-sm font-semibold text-ink truncate">
96
112
  {folder.name}
@@ -153,18 +169,39 @@ export function MobileSessionCategory({
153
169
 
154
170
  {isExpanded && (
155
171
  <div className="mt-0.5 space-y-0.5 pl-2">
156
- {visibleSessions.map((session) => (
157
- <MobileSessionRow
158
- key={session.id}
159
- session={session}
160
- isActive={String(activeSessionId) === String(session.id)}
161
- status={sessionStatus[String(session.id)]}
162
- timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
163
- onSelect={() => onSelectSession(session.id)}
164
- onArchive={() => handleArchive(session)}
165
- onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
166
- />
167
- ))}
172
+ {visibleSessions.map((session) => {
173
+ const sessionKey = String(session.id);
174
+ const isSessionActive = String(activeSessionId) === sessionKey;
175
+ // While one of its subagents is being viewed, the parent session
176
+ // row dims so the highlighted roster entry reads as the active one
177
+ // (desktop parity).
178
+ const isViewingSubagent = isSessionActive && urlSessionId === sessionKey && Boolean(urlSubagentId);
179
+ // Same gate as desktop: the omp-side loader flag is authoritative,
180
+ // so a session whose roster exists on disk can be opened without a
181
+ // live process.
182
+ const hasSubagents = Boolean(session.hasSubagents);
183
+ const isRosterOpen = hasSubagents && expandedSessionIds.has(sessionKey);
184
+
185
+ return (
186
+ <div key={session.id} className="space-y-0.5">
187
+ <MobileSessionRow
188
+ session={session}
189
+ isActive={isSessionActive && !isViewingSubagent}
190
+ status={sessionStatus[sessionKey]}
191
+ timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
192
+ hasSubagents={hasSubagents}
193
+ isExpanded={isRosterOpen}
194
+ onToggleExpand={() => toggleSession(sessionKey)}
195
+ onSelect={() => onSelectSession(session.id)}
196
+ onArchive={() => handleArchive(session)}
197
+ onRename={sessionKey.startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
198
+ />
199
+ {isRosterOpen && (
200
+ <SubagentList sessionId={session.id} isActiveSession={isSessionActive} />
201
+ )}
202
+ </div>
203
+ );
204
+ })}
168
205
 
169
206
  {filteredSessions.length > visibleCount && (
170
207
  <button
@@ -1,4 +1,4 @@
1
- import { Archive, ArchiveRestore, Check, Loader2, Pencil } from 'lucide-preact';
1
+ import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, Loader2, Pencil } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
 
@@ -8,7 +8,10 @@ export interface MobileSessionRowProps {
8
8
  status?: 'stream' | 'finish' | 'abort';
9
9
  /** Relative age of the session, or null when no usable timestamp exists. */
10
10
  timeAgo: string | null;
11
- showTreeGlyph?: boolean;
11
+ /** The session has a subagent roster (on disk or live), so it can be expanded. */
12
+ hasSubagents?: boolean;
13
+ isExpanded?: boolean;
14
+ onToggleExpand?: () => void;
12
15
  onSelect: () => void;
13
16
  onArchive: () => void;
14
17
  onRename?: (name: string) => void;
@@ -19,7 +22,9 @@ export function MobileSessionRow({
19
22
  isActive,
20
23
  status,
21
24
  timeAgo,
22
- showTreeGlyph = false,
25
+ hasSubagents = false,
26
+ isExpanded = false,
27
+ onToggleExpand,
23
28
  onSelect,
24
29
  onArchive,
25
30
  onRename,
@@ -34,6 +39,19 @@ export function MobileSessionRow({
34
39
  handleBlur,
35
40
  } = useInlineRename(session.title, onRename);
36
41
 
42
+ // The roster toggle is the row's LAST element, so its glyph lands in the same
43
+ // column as the folder header's expand chevron — one vertical line of
44
+ // disclosure controls down the drawer. The toggle is a SIBLING of the select
45
+ // button, never a child: a button inside a button is invalid and the browser
46
+ // reparents it.
47
+ //
48
+ // Touch has no hover to reveal a hidden toggle the way desktop does, so it is
49
+ // pinned whenever the row has a roster; a hidden one would leave the roster
50
+ // unreachable on a phone.
51
+ const showChevron = hasSubagents && Boolean(onToggleExpand);
52
+ const showStatus = Boolean(status);
53
+ const title = session.title.charAt(0).toUpperCase() + session.title.slice(1);
54
+
37
55
  if (isEditing) {
38
56
  return (
39
57
  <div className="w-full rounded-lg flex items-center bg-ink/10">
@@ -59,17 +77,16 @@ export function MobileSessionRow({
59
77
  <button
60
78
  type="button"
61
79
  onClick={onSelect}
62
- className="flex-1 text-left px-3 py-2 flex items-center justify-between min-w-0"
80
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
63
81
  >
64
- <div className="flex items-center space-x-1.5 min-w-0 pr-2">
82
+ <div className="flex items-center min-w-0 pr-2">
83
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
84
+ so the left column reads as one line. */}
65
85
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
66
- {status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
67
- {(status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
68
- </span>
69
- {showTreeGlyph && <span className="text-ink/40 text-xs flex-shrink-0 font-mono">&gt;</span>}
70
- <span className="text-xs truncate leading-snug">
71
- {session.title.charAt(0).toUpperCase() + session.title.slice(1)}
86
+ {showStatus && status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
87
+ {showStatus && (status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
72
88
  </span>
89
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
73
90
  </div>
74
91
 
75
92
  <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
@@ -86,14 +103,35 @@ export function MobileSessionRow({
86
103
  </button>
87
104
  )}
88
105
 
106
+ {/* A row WITHOUT a roster toggle still has to keep the toggle's column
107
+ empty, or its archive glyph would sit where the toggle is and the
108
+ trailing icons would step in and out row to row. This `mr-1` is what
109
+ reserves that column: it moves the 14px glyph's centre from x=363 to
110
+ x=359, the exact x the toggle uses on a row that has one. */}
89
111
  <button
90
112
  type="button"
91
113
  onClick={onArchive}
92
114
  title={session.is_archived === 1 ? 'Unarchive session' : 'Archive session'}
93
- className="flex-shrink-0 p-2 mr-1 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
115
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
94
116
  >
95
117
  {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
96
118
  </button>
119
+
120
+ {/* Last element, so its glyph sits in the folder header's chevron column.
121
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
122
+ touch-sized tap target. */}
123
+ {showChevron && (
124
+ <button
125
+ type="button"
126
+ onClick={onToggleExpand}
127
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
128
+ aria-expanded={isExpanded}
129
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
130
+ className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
131
+ >
132
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
133
+ </button>
134
+ )}
97
135
  </div>
98
136
  );
99
137
  }
@@ -1,5 +1,5 @@
1
1
  import type { RefObject } from 'preact/compat';
2
- import { Archive, ArrowUpDown, Calendar, Check, FolderPlus, Plus, Search, X } from 'lucide-preact';
2
+ import { Archive, ArrowUpDown, Calendar, Check, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
3
3
  import type { SessionSortOption } from '@/shared/types';
4
4
  import { SortMenu } from '@/client/components/common/sort-menu';
5
5
 
@@ -12,6 +12,9 @@ interface MobileSessionToolbarProps {
12
12
  onNewSession: () => void;
13
13
  onNewWorkspace: () => void;
14
14
  onScheduler: () => void;
15
+ onRefresh: () => void;
16
+ /** True while the user-initiated refresh is in flight — spins the icon. */
17
+ refreshing: boolean;
15
18
  onToggleOptions: () => void;
16
19
  onSortChange: (opt: SessionSortOption) => void;
17
20
  onResetSort: () => void;
@@ -30,6 +33,8 @@ export function MobileSessionToolbar({
30
33
  onNewSession,
31
34
  onNewWorkspace,
32
35
  onScheduler,
36
+ onRefresh,
37
+ refreshing,
33
38
  onToggleOptions,
34
39
  onSortChange,
35
40
  onResetSort,
@@ -74,6 +79,21 @@ export function MobileSessionToolbar({
74
79
  <Calendar size={16} strokeWidth={1.8} />
75
80
  </button>
76
81
 
82
+ {/* Refresh sessions button — same action the desktop toolbar's
83
+ RefreshCw performs: refetch the session list from the server.
84
+ The icon spins for the duration of that fetch, so the click has a
85
+ visible end; `refreshing` is raised only by this button's own load. */}
86
+ <button
87
+ type="button"
88
+ onClick={onRefresh}
89
+ className="w-9.5 h-9.5 rounded-xl border border-ink/15 bg-paper hover:bg-ink/5 active:scale-95 text-ink/80 hover:text-ink flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs"
90
+ title="Refresh sessions"
91
+ aria-label="Refresh sessions"
92
+ aria-busy={refreshing}
93
+ >
94
+ <RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
95
+ </button>
96
+
77
97
  {/* Sort & Filter Dropdown */}
78
98
  <div className="relative flex-shrink-0" ref={optionsRef}>
79
99
  <button