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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/timeline/actions.ts +24 -3
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +13 -1
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +38 -8
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /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 {
|
|
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:
|
|
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).
|
|
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:
|
|
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({
|
|
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).
|
|
61
|
-
expect(merged.diff).
|
|
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({
|
|
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
|
});
|