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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The preference rules are shared verbatim by the server (which persists the
8
+ * arrays) and the picker (which applies them optimistically), so these lock the
9
+ * behaviour both sides read back: composite keys, most-recent-first order, and
10
+ * the five-entry cap.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import {
16
+ EMPTY_MODEL_PREFERENCES,
17
+ RECENT_MODELS_LIMIT,
18
+ applyModelPreferences,
19
+ filterKnownKeys,
20
+ readModelPreferences,
21
+ recordRecentKey,
22
+ toggleFavoriteKey,
23
+ } from '@/shared/lib/models/preferences';
24
+ import type { AIModelOption } from '@/shared/types';
25
+
26
+ describe('toggleFavoriteKey', () => {
27
+ test('adds an unknown key at the end and removes a known one', () => {
28
+ expect(toggleFavoriteKey(['a:1'], 'b:2')).toEqual(['a:1', 'b:2']);
29
+ expect(toggleFavoriteKey(['a:1', 'b:2'], 'a:1')).toEqual(['b:2']);
30
+ });
31
+
32
+ test('keys on provider + id, so the same id under another provider is a separate favorite', () => {
33
+ // The registry serves `deepseek-v4-pro` from both `deepseek` and `kenari`.
34
+ // An id-only list would unstar both rows when either is clicked.
35
+ const favorites = toggleFavoriteKey([], 'deepseek:deepseek-v4-pro');
36
+ expect(toggleFavoriteKey(favorites, 'kenari:deepseek-v4-pro')).toEqual([
37
+ 'deepseek:deepseek-v4-pro',
38
+ 'kenari:deepseek-v4-pro',
39
+ ]);
40
+ });
41
+ });
42
+
43
+ describe('recordRecentKey', () => {
44
+ test('moves a re-picked model to the front instead of duplicating it', () => {
45
+ expect(recordRecentKey(['a:1', 'b:2', 'c:3'], 'b:2')).toEqual(['b:2', 'a:1', 'c:3']);
46
+ });
47
+
48
+ test('caps the list at five, evicting the oldest entry', () => {
49
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
50
+ const next = recordRecentKey(full, 'f:6');
51
+
52
+ expect(next).toHaveLength(RECENT_MODELS_LIMIT);
53
+ expect(next[0]).toBe('f:6');
54
+ expect(next).not.toContain('e:5');
55
+ });
56
+
57
+ test('re-picking inside a full list keeps the other four', () => {
58
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
59
+ expect(recordRecentKey(full, 'c:3')).toEqual(['c:3', 'a:1', 'b:2', 'd:4', 'e:5']);
60
+ });
61
+ });
62
+
63
+ describe('readModelPreferences', () => {
64
+ test('reads a well-formed pair', () => {
65
+ expect(readModelPreferences({ favorites: ['a:1'], recentKeys: ['b:2'] }))
66
+ .toEqual({ favorites: ['a:1'], recentKeys: ['b:2'] });
67
+ });
68
+
69
+ test('drops non-string entries and caps an over-long recent list', () => {
70
+ const raw = {
71
+ favorites: ['a:1', 7, null, ''],
72
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5', 'f:6', 'g:7'],
73
+ };
74
+
75
+ expect(readModelPreferences(raw)).toEqual({
76
+ favorites: ['a:1'],
77
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'],
78
+ });
79
+ });
80
+
81
+ test('an absent or malformed row yields empty rails', () => {
82
+ expect(readModelPreferences(null)).toEqual(EMPTY_MODEL_PREFERENCES);
83
+ expect(readModelPreferences('nonsense')).toEqual(EMPTY_MODEL_PREFERENCES);
84
+ expect(readModelPreferences({ favorites: 'not-a-list' })).toEqual(EMPTY_MODEL_PREFERENCES);
85
+ });
86
+ });
87
+
88
+ describe('filterKnownKeys', () => {
89
+ test('drops keys the registry no longer serves', () => {
90
+ // A model removed from models.yml, or every model of a disconnected
91
+ // provider, must not hold a slot in the five-entry rail.
92
+ const known = new Set(['a:1', 'c:3']);
93
+ expect(filterKnownKeys(['a:1', 'gone:9', 'c:3'], known)).toEqual(['a:1', 'c:3']);
94
+ });
95
+
96
+ test('pruning before a write frees the slot instead of merely hiding it', () => {
97
+ // The rail reads at most five entries. A stale key left in the stored array
98
+ // occupies one of them across every reload, so the user sees four models
99
+ // and the fifth pick appears to do nothing.
100
+ const stored = ['gone:9', 'a:1', 'b:2', 'c:3', 'd:4'];
101
+ const pruned = recordRecentKey(filterKnownKeys(stored, new Set(['a:1', 'b:2', 'c:3', 'd:4', 'e:5'])), 'e:5');
102
+
103
+ expect(pruned).toEqual(['e:5', 'a:1', 'b:2', 'c:3', 'd:4']);
104
+ expect(pruned).toHaveLength(RECENT_MODELS_LIMIT);
105
+ });
106
+ });
107
+
108
+ describe('applyModelPreferences', () => {
109
+ const rows: AIModelOption[] = [
110
+ { id: '1', name: 'One', provider: 'a' },
111
+ { id: '2', name: 'Two', provider: 'a', isFavorite: true },
112
+ { id: '3', name: 'Three', provider: 'b' },
113
+ ];
114
+
115
+ test('lights only the rows named by the stored key list', () => {
116
+ const applied = applyModelPreferences(rows, { favorites: ['b:3'], recentKeys: [] });
117
+
118
+ // Truthiness is the contract the star reads: a registry row has no
119
+ // `isFavorite` field at all, so `false` and `undefined` both mean "unlit".
120
+ expect(applied.map((m) => Boolean(m.isFavorite))).toEqual([false, false, true]);
121
+ });
122
+
123
+ test('leaves already-correct rows identical so only the changed row re-renders', () => {
124
+ const applied = applyModelPreferences(rows, { favorites: [], recentKeys: [] });
125
+
126
+ expect(applied[0]).toBe(rows[0]);
127
+ expect(applied[2]).toBe(rows[2]);
128
+ expect(applied[1]).not.toBe(rows[1]);
129
+ expect(applied[1].isFavorite).toBe(false);
130
+ });
131
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Favorite + recent model preferences, keyed by the composite `provider:id`
8
+ * identity (`modelKey`). A model id alone is not unique — the omp registry
9
+ * serves the same id from several providers — so an id-keyed favorite list
10
+ * lights up every provider's row and records the wrong "recent".
11
+ *
12
+ * Pure list operations only: the server persists the arrays (one
13
+ * `app_settings` row) and the picker applies them optimistically, so both
14
+ * sides MUST agree on the ordering and the cap. Keeping the rules here is what
15
+ * makes them agree — a second copy in the route or the hook would drift.
16
+ */
17
+
18
+ import type { AIModelOption, ModelPreferences } from '@/shared/types';
19
+ import { modelKey } from '@/shared/lib/models/identity';
20
+
21
+ /** How many last-used models the RECENT rail holds. */
22
+ export const RECENT_MODELS_LIMIT = 5;
23
+
24
+ /** Used before the first write, and when a payload carries no preferences. */
25
+ export const EMPTY_MODEL_PREFERENCES: ModelPreferences = { favorites: [], recentKeys: [] };
26
+
27
+ /** Add or remove one key; the relative order of the survivors is untouched. */
28
+ export function toggleFavoriteKey(favorites: readonly string[], key: string): string[] {
29
+ return favorites.includes(key)
30
+ ? favorites.filter((entry) => entry !== key)
31
+ : [...favorites, key];
32
+ }
33
+
34
+ /**
35
+ * Move a key to the front of the recents, de-duplicated and capped. Re-picking
36
+ * a model must not leave a second copy behind — the rail would then show the
37
+ * same model twice and the cap would evict a genuinely older entry.
38
+ */
39
+ export function recordRecentKey(
40
+ recentKeys: readonly string[],
41
+ key: string,
42
+ limit: number = RECENT_MODELS_LIMIT,
43
+ ): string[] {
44
+ return [key, ...recentKeys.filter((entry) => entry !== key)].slice(0, Math.max(1, limit));
45
+ }
46
+
47
+ /**
48
+ * Drop keys the live registry no longer offers — a model removed from
49
+ * models.yml, or every model of a provider the user disconnected. Without this
50
+ * a stale key holds one of the five recent slots forever, so a rail that looks
51
+ * empty is hiding real entries behind keys nothing can render.
52
+ */
53
+ export function filterKnownKeys(keys: readonly string[], known: ReadonlySet<string>): string[] {
54
+ return keys.filter((key) => known.has(key));
55
+ }
56
+
57
+ /** Parse a persisted preferences row without trusting the JSON shape. */
58
+ export function readModelPreferences(value: unknown): ModelPreferences {
59
+ const source = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
60
+ const strings = (input: unknown): string[] =>
61
+ Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
62
+ return {
63
+ favorites: strings(source.favorites),
64
+ recentKeys: strings(source.recentKeys).slice(0, RECENT_MODELS_LIMIT),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Stamp the favorite flag onto a catalog list. The registry carries no
70
+ * favorites of its own — it is a read-only view of omp's config — so the picker
71
+ * learns which rows are starred from the preference store, never from the
72
+ * catalog's own `isFavorite` (that is MOCK seed data).
73
+ *
74
+ * The comparison is by truthiness, not identity: a registry row has no
75
+ * `isFavorite` field at all, so an `undefined !== false` test would rebuild
76
+ * every row on every load and re-render the whole panel. Rows whose star state
77
+ * already reads correctly are returned untouched.
78
+ */
79
+ export function applyModelPreferences(
80
+ models: AIModelOption[],
81
+ preferences: ModelPreferences,
82
+ ): AIModelOption[] {
83
+ const favorites = new Set(preferences.favorites);
84
+ return models.map((model) => {
85
+ const isFavorite = favorites.has(modelKey(model));
86
+ return Boolean(model.isFavorite) === isFavorite ? model : { ...model, isFavorite };
87
+ });
88
+ }
@@ -63,6 +63,14 @@ export function mergeProviderModels(
63
63
  return { merged: [...existing, ...added], added, addedCount: added.length };
64
64
  }
65
65
 
66
+ /**
67
+ * Register newly added provider models on the chamber's catalog.
68
+ *
69
+ * `isFavorite` is deliberately NOT sent: the picker derives favorites from the
70
+ * stored preference keys, so a catalog flag would be a second, silently
71
+ * ignored source of truth for the FAVORITES rail. A new provider's models are
72
+ * starred by the user, not by this sync.
73
+ */
66
74
  export async function syncProviderModelsToCatalog(
67
75
  providerName: string,
68
76
  models: ProviderModel[],
@@ -81,7 +89,6 @@ export async function syncProviderModelsToCatalog(
81
89
  provider: providerName,
82
90
  contextWindow: model.contextWindow?.split(' ')[0] || '128K',
83
91
  thinkingLevel: 'Default',
84
- isFavorite: true,
85
92
  capabilities: ['Tool calling', 'Reasoning'],
86
93
  inputFormats: ['text'],
87
94
  outputFormats: ['text'],
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Covers the boundaries of the subagent label derivation: the Target-section
8
+ * scan (heading level, colon variant, section end) and the two identity shapes.
9
+ *
10
+ * These are the cases where the label is derived from prompt text the caller
11
+ * controls, so a wrong boundary silently renders template boilerplate or a
12
+ * section name where the assignment's headline belongs.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { assignmentTarget, subagentHeaderTitle, subagentRowLabel } from '@/shared/lib/omp/subagent/label';
18
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
19
+
20
+ /** Minimal roster entry — only the fields the label helpers read. */
21
+ function makeSubagent(overrides: Partial<SubagentInfo> = {}): SubagentInfo {
22
+ return { id: 'Scout', agent: 'task', status: 'completed', index: 0, ...overrides };
23
+ }
24
+
25
+ describe('assignmentTarget', () => {
26
+ test('reads the headline under the task template Target section', () => {
27
+ const task = 'Complete assignment thoroughly:\n\n# Target\nCount .tsx files under src/.\n\n# Change\nNone.\n\n# Acceptance\nReport the integer.';
28
+ expect(assignmentTarget(task)).toBe('Count .tsx files under src/.');
29
+ });
30
+
31
+ test('stops at the next heading so Change prose never becomes the headline', () => {
32
+ const task = '# Target\nMap the panel data model.\n\n# Change\nDo the thing';
33
+ expect(assignmentTarget(task)).toBe('Map the panel data model.');
34
+ });
35
+
36
+ test('accepts any heading level and a trailing colon', () => {
37
+ expect(assignmentTarget('## Target:\nHeadline here')).toBe('Headline here');
38
+ expect(assignmentTarget('### Target\nHeadline here')).toBe('Headline here');
39
+ });
40
+
41
+ test('skips an empty Target section instead of promoting the next section', () => {
42
+ expect(assignmentTarget('# Target\n\n# Change\nDo the thing')).toBe('Do the thing');
43
+ });
44
+
45
+ test('falls back to the first prose line when no Target section exists', () => {
46
+ expect(assignmentTarget('Fix the sidebar expand bug')).toBe('Fix the sidebar expand bug');
47
+ expect(assignmentTarget('# Scope\nNot a target section')).toBe('Not a target section');
48
+ });
49
+
50
+ test('never returns the template lead-in or a bare heading', () => {
51
+ expect(assignmentTarget('Complete assignment thoroughly:')).toBeUndefined();
52
+ expect(assignmentTarget('# Target')).toBeUndefined();
53
+ });
54
+
55
+ test('returns undefined for absent or empty text', () => {
56
+ expect(assignmentTarget(undefined)).toBeUndefined();
57
+ expect(assignmentTarget(' \n\n ')).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('subagentHeaderTitle', () => {
62
+ test('appends the model role recorded on a history entry', () => {
63
+ expect(subagentHeaderTitle(makeSubagent({ modelRole: 'smol' }))).toBe('Scout (task:smol)');
64
+ });
65
+
66
+ test('falls back to the role carried by a live progress frame', () => {
67
+ expect(subagentHeaderTitle(makeSubagent({ progress: { modelRole: 'task' } }))).toBe('Scout (task:task)');
68
+ });
69
+
70
+ test('omits the role when the run recorded none', () => {
71
+ expect(subagentHeaderTitle(makeSubagent())).toBe('Scout (task)');
72
+ });
73
+ });
74
+
75
+ describe('subagentRowLabel', () => {
76
+ test('appends the assignment headline to the identity', () => {
77
+ const subagent = makeSubagent({ task: 'Complete assignment thoroughly:\n\n# Target\nHeadline here.\n\n# Change\nx' });
78
+ expect(subagentRowLabel(subagent)).toBe('Scout (task): Headline here.');
79
+ });
80
+
81
+ test('reads the assignment field when the task template is absent', () => {
82
+ expect(subagentRowLabel(makeSubagent({ assignment: '# Target\nFrom the assignment field.' })))
83
+ .toBe('Scout (task): From the assignment field.');
84
+ });
85
+
86
+ test('falls back to the description, then to the bare identity', () => {
87
+ expect(subagentRowLabel(makeSubagent({ description: 'fallback description' }))).toBe('Scout (task): fallback description');
88
+ expect(subagentRowLabel(makeSubagent())).toBe('Scout (task)');
89
+ });
90
+
91
+ test('keeps the identity when the assignment has no usable headline', () => {
92
+ expect(subagentRowLabel(makeSubagent({ task: '# Target' }))).toBe('Scout (task)');
93
+ });
94
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One-line labels for a subagent roster entry.
8
+ *
9
+ * A delegated assignment is a task prompt, not a sentence. The wire carries it
10
+ * twice: `assignment` is the document the caller wrote, and `task` is that same
11
+ * document wrapped by the task tool's template ("Complete assignment
12
+ * thoroughly:" followed by `# Target` / `# Change` / `# Acceptance` sections).
13
+ * Rendering either verbatim in a 268px sidebar row would spend the whole row on
14
+ * the template's lead-in, so a row shows the Target section's headline — the
15
+ * one line that says what the agent is for.
16
+ *
17
+ * The two surfaces differ only in what they append to the identity:
18
+ * roster row → `id (agent): target`
19
+ * banner → `id (agent:modelRole)`
20
+ */
21
+
22
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
23
+
24
+ /** Any markdown heading — the boundary between prompt sections. */
25
+ const HEADING_RE = /^#{1,6}\s+\S/;
26
+ /** The section the task prompt template mandates as the assignment's summary. */
27
+ const TARGET_HEADING_RE = /^#{1,6}\s+target\s*:?\s*$/i;
28
+ /** The task template's own lead-in; it says nothing about the assignment. */
29
+ const LEAD_IN_RE = /^complete (the )?assignment\b/i;
30
+
31
+ /** First line of `lines` that has content, trimmed. */
32
+ function firstLine(lines: string[]): string | undefined {
33
+ for (const line of lines) {
34
+ const trimmed = line.trim();
35
+ if (trimmed) return trimmed;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * The headline of a delegated assignment: the first line of its `# Target`
42
+ * section, falling back to the prompt's own first prose line when there is no
43
+ * such section (or it is empty).
44
+ */
45
+ export function assignmentTarget(text: string | undefined): string | undefined {
46
+ if (!text) return undefined;
47
+ const lines = text.split('\n');
48
+ const headingAt = lines.findIndex((line) => TARGET_HEADING_RE.test(line.trim()));
49
+ if (headingAt !== -1) {
50
+ // Bounded by the next heading: an empty Target section must not promote the
51
+ // Change/Acceptance prose that follows it into the headline.
52
+ const body = lines.slice(headingAt + 1);
53
+ const nextHeading = body.findIndex((line) => HEADING_RE.test(line.trim()));
54
+ const headline = firstLine(nextHeading === -1 ? body : body.slice(0, nextHeading));
55
+ if (headline) return headline;
56
+ }
57
+ // No Target section, or an empty one: the prompt's first prose line. Headings
58
+ // are skipped because a section name is not a summary of the assignment.
59
+ return firstLine(lines.filter((line) => {
60
+ const trimmed = line.trim();
61
+ return !HEADING_RE.test(trimmed) && !LEAD_IN_RE.test(trimmed);
62
+ }));
63
+ }
64
+
65
+ /** `id (agent:modelRole)`, or `id (agent)` when no role was recorded. Live
66
+ * snapshots carry the role on `progress`; history entries carry it top-level,
67
+ * because the on-disk fold records it from the parent's task toolResult rather
68
+ * than from a progress frame. */
69
+ export function subagentHeaderTitle(subagent: SubagentInfo): string {
70
+ const role = subagent.modelRole ?? subagent.progress?.modelRole;
71
+ return role
72
+ ? `${subagent.id} (${subagent.agent}:${role})`
73
+ : `${subagent.id} (${subagent.agent})`;
74
+ }
75
+
76
+ /** `id (agent): target` — the roster row's one-line label. The description is
77
+ * the fallback for entries whose assignment text never reached the roster. */
78
+ export function subagentRowLabel(subagent: SubagentInfo): string {
79
+ const target = assignmentTarget(subagent.task)
80
+ ?? assignmentTarget(subagent.assignment)
81
+ ?? subagent.description;
82
+ const identity = `${subagent.id} (${subagent.agent})`;
83
+ return target ? `${identity}: ${target}` : identity;
84
+ }
@@ -5,37 +5,63 @@
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
- import { mergePanelWidths, normalizePanelWidths } from '@/shared/lib/workspace/panel-widths';
8
+ import {
9
+ HANDLE_WIDTH,
10
+ MIN_CHAT_PANEL_WIDTH,
11
+ mergePanelWidths,
12
+ normalizePanelWidths,
13
+ resolvePanelWidth,
14
+ } from '@/shared/lib/workspace/panel-widths';
9
15
 
10
16
  describe('normalizePanelWidths', () => {
11
17
  test('keeps every panel slot from a current blob', () => {
12
18
  const raw = {
13
19
  left: 342,
14
- editor: 620,
15
- diff: 900,
16
- right: { files: 300, browser: 804 },
20
+ editor: { px: 620, fraction: 0.54 },
21
+ diff: { fraction: 0.6 },
22
+ right: { files: { px: 300, fraction: 0.26 }, browser: { px: 804 } },
17
23
  };
18
24
 
19
25
  expect(normalizePanelWidths(raw, 'files')).toEqual(raw);
20
26
  });
21
27
 
28
+ test('reads a bare number as the px-only shape and keeps it for conversion', () => {
29
+ // Blobs written before fractions existed: the pixel width must survive so
30
+ // the layout can convert it once the group's area is known.
31
+ const widths = normalizePanelWidths({ left: 342, editor: 620, right: { files: 300 } }, 'files');
32
+
33
+ expect(widths).toEqual({ left: 342, editor: { px: 620 }, right: { files: { px: 300 } } });
34
+ });
35
+
22
36
  test('adopts a legacy shared right width into the view that was open', () => {
23
37
  const widths = normalizePanelWidths({ left: 342, editor: 620, right: 1091 }, 'browser');
24
38
 
25
- expect(widths.right).toEqual({ browser: 1091 });
39
+ expect(widths.right).toEqual({ browser: { px: 1091 } });
26
40
  expect(widths.left).toBe(342);
27
- expect(widths.editor).toBe(620);
41
+ expect(widths.editor).toEqual({ px: 620 });
42
+ });
43
+
44
+ test('drops an out-of-range fraction instead of trusting it', () => {
45
+ // A fraction is a share of the area, so 0 and >1 are meaningless; the px
46
+ // fallback in the same slot survives, and a slot left with neither unit
47
+ // disappears rather than being kept as an empty object.
48
+ const widths = normalizePanelWidths(
49
+ { editor: { px: 620, fraction: 1.25 }, diff: { fraction: 0 }, right: { files: { fraction: -0.5 } } },
50
+ 'files',
51
+ );
52
+
53
+ expect(widths).toEqual({ editor: { px: 620 } });
28
54
  });
29
55
 
30
56
  test('drops unusable entries instead of trusting the blob', () => {
31
57
  // `chat` is an obsolete slot — the chat column is the filler now — and a
32
58
  // blob written before that must not resurrect it.
33
59
  const widths = normalizePanelWidths(
34
- { left: -10, chat: 700, editor: 620.4, diff: null, right: { files: 0, bogus: 300, git: 260 } },
60
+ { left: -10, chat: 700, editor: { px: 620.4 }, diff: null, right: { files: { px: 0 }, bogus: 300, git: { px: 260 } } },
35
61
  'files',
36
62
  );
37
63
 
38
- expect(widths).toEqual({ editor: 620, right: { git: 260 } });
64
+ expect(widths).toEqual({ editor: { px: 620 }, right: { git: { px: 260 } } });
39
65
  });
40
66
 
41
67
  test('tolerates a missing or malformed blob', () => {
@@ -48,25 +74,94 @@ describe('mergePanelWidths', () => {
48
74
  test('merging one view keeps every other remembered width', () => {
49
75
  const stored = {
50
76
  left: 342,
51
- editor: 620,
52
- diff: 900,
53
- right: { files: 300, git: 260 },
77
+ editor: { px: 620, fraction: 0.54 },
78
+ diff: { fraction: 0.6 },
79
+ right: { files: { px: 300, fraction: 0.26 }, git: { px: 260 } },
54
80
  };
55
81
 
56
- const merged = mergePanelWidths(stored, { right: { browser: 804 } });
82
+ const merged = mergePanelWidths(stored, { right: { browser: { px: 804, fraction: 0.7 } } });
57
83
 
58
- expect(merged.right).toEqual({ files: 300, git: 260, browser: 804 });
84
+ expect(merged.right).toEqual({
85
+ files: { px: 300, fraction: 0.26 },
86
+ git: { px: 260 },
87
+ browser: { px: 804, fraction: 0.7 },
88
+ });
59
89
  expect(merged.left).toBe(342);
60
- expect(merged.editor).toBe(620);
61
- expect(merged.diff).toBe(900);
90
+ expect(merged.editor).toEqual({ px: 620, fraction: 0.54 });
91
+ expect(merged.diff).toEqual({ fraction: 0.6 });
92
+ });
93
+
94
+ test('a patch adds a unit without discarding the one already stored', () => {
95
+ // A drag reports px + fraction together, but the conversion pass reports a
96
+ // fraction alone; it must not erase the px fallback it was derived from.
97
+ const merged = mergePanelWidths({ editor: { px: 620 } }, { editor: { fraction: 0.54 } });
98
+
99
+ expect(merged.editor).toEqual({ px: 620, fraction: 0.54 });
62
100
  });
63
101
 
64
102
  test('a resized panel replaces only its own slot', () => {
65
103
  const merged = mergePanelWidths(
66
- { left: 300, editor: 620, diff: 900, right: { files: 300 } },
67
- { editor: 480 },
104
+ { left: 300, editor: { px: 620 }, diff: { px: 900 }, right: { files: { px: 300 } } },
105
+ { editor: { px: 480, fraction: 0.4 } },
68
106
  );
69
107
 
70
- expect(merged).toEqual({ left: 300, editor: 480, diff: 900, right: { files: 300 } });
108
+ expect(merged).toEqual({
109
+ left: 300,
110
+ editor: { px: 480, fraction: 0.4 },
111
+ diff: { px: 900 },
112
+ right: { files: { px: 300 } },
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('resolvePanelWidth', () => {
118
+ const base = {
119
+ defaultFraction: 0.6,
120
+ defaultPx: 600,
121
+ min: 320,
122
+ siblingMin: 0,
123
+ handleCount: 0,
124
+ };
125
+
126
+ test('opens at its default share of the measured area', () => {
127
+ expect(resolvePanelWidth({ ...base, available: 1000 })).toBe(600);
128
+ });
129
+
130
+ test('falls back to the pixel width before the area is known', () => {
131
+ // Not zero: a panel whose area has not been measured yet must still render
132
+ // at the width it is supposed to open at.
133
+ expect(resolvePanelWidth({ ...base, available: null })).toBe(600);
134
+ expect(resolvePanelWidth({ ...base, available: null, stored: { px: 512 } })).toBe(512);
135
+ });
136
+
137
+ test('a px-only value holds its pixel width; only a fraction follows the area', () => {
138
+ // The conversion is an identity (`px / available * available`), which is
139
+ // the point: a pixel value someone chose is honoured as written rather than
140
+ // being rescaled behind their back. It stops following the window only
141
+ // until that slot is dragged once, which stores a fraction alongside it.
142
+ expect(resolvePanelWidth({ ...base, available: 1000, stored: { px: 480 } })).toBe(480);
143
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480 } })).toBe(480);
144
+ // With both units stored, the fraction is what the panel follows.
145
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480, fraction: 0.24 } })).toBe(480);
146
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480, fraction: 0.3 } })).toBe(600);
147
+ });
148
+
149
+ test('a stored fraction beats the default share', () => {
150
+ // 0.45 of 1000 is 450: above the 320 floor and below the 0.6 default, so
151
+ // the result can only come from the stored fraction.
152
+ expect(resolvePanelWidth({ ...base, available: 1000, stored: { px: 480, fraction: 0.45 } })).toBe(450);
153
+ });
154
+
155
+ test('the ceiling leaves the chat its floor, the siblings theirs, and the handles their width', () => {
156
+ const available = 2000;
157
+ const expected = available - MIN_CHAT_PANEL_WIDTH - 320 - 2 * HANDLE_WIDTH;
158
+
159
+ expect(resolvePanelWidth({ ...base, available, siblingMin: 320, handleCount: 2, stored: { fraction: 0.9 } })).toBe(expected);
160
+ });
161
+
162
+ test('the floor wins over a ceiling squeezed below it', () => {
163
+ // A window too narrow for the chat floor, a sibling and this panel: the
164
+ // panel keeps its minimum and the row clips, rather than collapsing.
165
+ expect(resolvePanelWidth({ ...base, available: 400, siblingMin: 320, handleCount: 2 })).toBe(320);
71
166
  });
72
167
  });