ompchamber 1.0.0 → 1.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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -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/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -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/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- 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/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- 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 +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- 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/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- 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/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- 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/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recovery of inlined text attachments from a delivered prompt.
|
|
8
|
+
*
|
|
9
|
+
* The composer does not send a text file as data — it appends the file to the
|
|
10
|
+
* prompt as `Attached file: <name>` plus a fenced block, and that composed
|
|
11
|
+
* prompt is the only record omp keeps. `stripInlinedTextAttachments` removes
|
|
12
|
+
* those blocks so the timeline shows the user's own words, which also discards
|
|
13
|
+
* the names: a reloaded session then showed an image chip and nothing for the
|
|
14
|
+
* `.md`/`.py`/`.json` that rode beside it.
|
|
15
|
+
*
|
|
16
|
+
* These tests pin the round trip, including the adversarial cases the format
|
|
17
|
+
* allows: content that itself contains a run of backticks (the fence grows to
|
|
18
|
+
* outrun it) and content that literally reads `Attached file:`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
import {
|
|
23
|
+
extractInlinedTextAttachments,
|
|
24
|
+
stripInlinedTextAttachments,
|
|
25
|
+
} from '@/shared/lib/omp/session/parse-message-blocks';
|
|
26
|
+
|
|
27
|
+
describe('extractInlinedTextAttachments', () => {
|
|
28
|
+
test('recovers a file the composer appended to a prompt', () => {
|
|
29
|
+
const prompt = 'explain this\n\nAttached file: script.py\n```python\nx=1\n```';
|
|
30
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([{ name: 'script.py', content: 'x=1' }]);
|
|
31
|
+
// The visible text is the user's own words; the block is not shown twice.
|
|
32
|
+
expect(stripInlinedTextAttachments(prompt)).toBe('explain this');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('recovers an attach-only prompt, whose visible text is empty', () => {
|
|
36
|
+
const prompt = 'Attached file: attachment-test.md\n```markdown\n# Title\n\nbody\n\n```';
|
|
37
|
+
// Trailing newline before the closing fence belongs to the content: the
|
|
38
|
+
// composer writes `<content>\n<fence>`.
|
|
39
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
40
|
+
{ name: 'attachment-test.md', content: '# Title\n\nbody\n' },
|
|
41
|
+
]);
|
|
42
|
+
expect(stripInlinedTextAttachments(prompt)).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('recovers every file when several were attached', () => {
|
|
46
|
+
const prompt = 'q\n\nAttached file: a.md\n```markdown\nA\n```\n\nAttached file: b.py\n```python\nB\n```';
|
|
47
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
48
|
+
{ name: 'a.md', content: 'A' },
|
|
49
|
+
{ name: 'b.py', content: 'B' },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('leaves a prompt with no attachments alone', () => {
|
|
54
|
+
expect(extractInlinedTextAttachments('just a normal message')).toEqual([]);
|
|
55
|
+
expect(stripInlinedTextAttachments('just a normal message')).toBe('just a normal message');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not mistake prose that merely mentions the header', () => {
|
|
59
|
+
// No fence follows, so this is not a block — it is the user talking.
|
|
60
|
+
expect(extractInlinedTextAttachments('I saw an Attached file: note in the docs')).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('keeps content that contains a backtick run', () => {
|
|
64
|
+
// `fenceForContent` grows the fence past the longest run in the content, so
|
|
65
|
+
// the inner ``` must survive as content and the outer ```` is the close.
|
|
66
|
+
const prompt = 'q\n\nAttached file: nested.md\n````markdown\nbefore\n```\ncode\n```\nafter\n````';
|
|
67
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
68
|
+
{ name: 'nested.md', content: 'before\n```\ncode\n```\nafter' },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('keeps content that literally contains the header', () => {
|
|
73
|
+
const prompt = 'q\n\nAttached file: tricky.md\n````markdown\nAttached file: fake.txt\n```\ninner\n```\n````';
|
|
74
|
+
expect(extractInlinedTextAttachments(prompt)).toEqual([
|
|
75
|
+
{ name: 'tricky.md', content: 'Attached file: fake.txt\n```\ninner\n```' },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an unterminated block yields nothing rather than a partial read', () => {
|
|
80
|
+
expect(extractInlinedTextAttachments('q\n\nAttached file: open.md\n```markdown\nno close')).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ChatMessageData, ToolCallData } from '@/shared/types';
|
|
16
|
-
import { extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
16
|
+
import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
17
17
|
import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
|
|
18
18
|
import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
|
|
19
19
|
import { formatClock } from '@/shared/lib/format/time';
|
|
@@ -95,8 +95,22 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
if (role === 'user') {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
98
|
+
const rawText = parsed.textParts.join('\n');
|
|
99
|
+
const text = stripInlinedTextAttachments(rawText);
|
|
100
|
+
// Parity with the JSONL path: the composer's inlined `Attached file:` blocks
|
|
101
|
+
// are the only record of a text attachment, so they are read back into
|
|
102
|
+
// chips here too. Without this a text file vanished from the timeline as
|
|
103
|
+
// soon as the turn was re-rendered from the wire.
|
|
104
|
+
const attachments = [
|
|
105
|
+
...extractUserImageAttachments(raw.content),
|
|
106
|
+
...extractInlinedTextAttachments(rawText).map((file, index) => ({
|
|
107
|
+
id: `text-${index + 1}`,
|
|
108
|
+
name: file.name,
|
|
109
|
+
type: 'text/plain',
|
|
110
|
+
preview: '',
|
|
111
|
+
content: file.content,
|
|
112
|
+
})),
|
|
113
|
+
];
|
|
100
114
|
return {
|
|
101
115
|
id,
|
|
102
116
|
role,
|
|
@@ -105,7 +119,13 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
|
|
|
105
119
|
startedAt,
|
|
106
120
|
content: text,
|
|
107
121
|
attribution,
|
|
108
|
-
attachments: attachments.map((a) => ({
|
|
122
|
+
attachments: attachments.map((a) => ({
|
|
123
|
+
id: a.id,
|
|
124
|
+
name: a.name,
|
|
125
|
+
preview: a.preview,
|
|
126
|
+
type: a.type,
|
|
127
|
+
...('content' in a ? { content: a.content } : {}),
|
|
128
|
+
})),
|
|
109
129
|
};
|
|
110
130
|
}
|
|
111
131
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
7
|
-
import { extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
7
|
+
import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
8
8
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
9
9
|
import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
|
|
10
10
|
import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
|
|
@@ -32,8 +32,21 @@ export function toChatMessage(entry: OmpMessageEntry): ChatMessageData | null {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
if (role === 'user') {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
35
|
+
const rawText = extractText(content);
|
|
36
|
+
const text = stripInlinedTextAttachments(rawText);
|
|
37
|
+
// omp keeps no attachment metadata for a text file: the composer inlined it
|
|
38
|
+
// into the prompt and that composed prompt is the only record. Recover the
|
|
39
|
+
// names from those blocks so a reloaded session still shows their chips —
|
|
40
|
+
// images survive on their own (they ride as a content block), text files
|
|
41
|
+
// did not, and disappeared from the timeline entirely.
|
|
42
|
+
const textAttachments = extractInlinedTextAttachments(rawText);
|
|
43
|
+
const attachments = [...extractUserImageAttachments(content), ...textAttachments.map((file, index) => ({
|
|
44
|
+
id: `text-${index + 1}`,
|
|
45
|
+
name: file.name,
|
|
46
|
+
type: 'text/plain',
|
|
47
|
+
preview: '',
|
|
48
|
+
content: file.content,
|
|
49
|
+
}))];
|
|
37
50
|
if (!text.trim() && attachments.length === 0) return null;
|
|
38
51
|
return { ...base, content: text, attachments };
|
|
39
52
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ToolCallData } from '@/shared/types/chat';
|
|
15
15
|
import type { OmpMessageEntry } from '@/shared/types/omp/session';
|
|
16
|
-
import { extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
16
|
+
import { extractInlinedTextAttachments, extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
17
17
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
18
18
|
|
|
19
19
|
export { stripInlinedTextAttachments };
|
|
@@ -66,4 +66,4 @@ export function parseAssistantContent(content: unknown): ParsedAssistantTurn {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export { extractUserImageAttachments };
|
|
69
|
+
export { extractUserImageAttachments, extractInlinedTextAttachments };
|
|
@@ -182,14 +182,37 @@ export function toToolCallData(
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* A user-attachment entry extracted from an omp content block. `blobRef` is set
|
|
187
|
+
* when omp externalized the bytes instead of inlining them (see
|
|
188
|
+
* `@/server/lib/omp/session/blobs.server`); the server resolves it before the
|
|
189
|
+
* timeline sees the attachment, so `preview` here is only a placeholder.
|
|
190
|
+
*/
|
|
191
|
+
export interface ExtractedImageAttachment {
|
|
192
|
+
id: string;
|
|
193
|
+
name: string;
|
|
194
|
+
preview: string;
|
|
195
|
+
type: string;
|
|
196
|
+
/** `blob:sha256:<hash>` when the bytes were externalized, else undefined. */
|
|
197
|
+
blobRef?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** True for the `blob:sha256:<hash>` references omp writes in place of image data. */
|
|
201
|
+
export function isBlobImageRef(data: string): boolean {
|
|
202
|
+
return data.startsWith('blob:sha256:');
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
/** Extract image blocks from an omp user content array ({type:'image',
|
|
186
|
-
* data: base64
|
|
187
|
-
* timeline keeps showing them after a reload from the session
|
|
206
|
+
* data: base64 | blob:sha256:<hash>, mimeType}) into ChatMessageData attachment
|
|
207
|
+
* entries so the timeline keeps showing them after a reload from the session
|
|
208
|
+
* JSONL. A blob ref carries no preview: building one from the ref string
|
|
209
|
+
* produced an undecodable `data:` URL, which is what the server-side resolver
|
|
210
|
+
* now replaces with the real bytes. */
|
|
188
211
|
export function extractUserImageAttachments(
|
|
189
212
|
content: unknown,
|
|
190
|
-
):
|
|
213
|
+
): ExtractedImageAttachment[] {
|
|
191
214
|
if (!Array.isArray(content)) return [];
|
|
192
|
-
const attachments:
|
|
215
|
+
const attachments: ExtractedImageAttachment[] = [];
|
|
193
216
|
let index = 0;
|
|
194
217
|
for (const block of content) {
|
|
195
218
|
if (!isRecord(block) || block.type !== 'image') continue;
|
|
@@ -198,11 +221,13 @@ export function extractUserImageAttachments(
|
|
|
198
221
|
if (!data) continue;
|
|
199
222
|
index += 1;
|
|
200
223
|
const ext = (mimeType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png';
|
|
224
|
+
const externalized = isBlobImageRef(data);
|
|
201
225
|
attachments.push({
|
|
202
226
|
id: `att-${index}`,
|
|
203
227
|
name: `attachment-${index}.${ext}`,
|
|
204
|
-
preview: `data:${mimeType};base64,${data}`,
|
|
228
|
+
preview: externalized ? '' : `data:${mimeType};base64,${data}`,
|
|
205
229
|
type: mimeType,
|
|
230
|
+
...(externalized ? { blobRef: data } : {}),
|
|
206
231
|
});
|
|
207
232
|
}
|
|
208
233
|
return attachments;
|
|
@@ -221,3 +246,58 @@ export function stripInlinedTextAttachments(text: string): string {
|
|
|
221
246
|
if (!/^[^\n]+\n```[a-z]*\n/.test(rest)) return text;
|
|
222
247
|
return before.trim();
|
|
223
248
|
}
|
|
249
|
+
|
|
250
|
+
/** One text file recovered from an inlined `Attached file:` block. */
|
|
251
|
+
export interface InlinedTextAttachment {
|
|
252
|
+
name: string;
|
|
253
|
+
content: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Recover the text files the composer inlined into a delivered prompt.
|
|
258
|
+
*
|
|
259
|
+
* omp stores no attachment metadata for a text file: the composer appends each
|
|
260
|
+
* one to the prompt as `Attached file: <name>` plus a fenced block, and that
|
|
261
|
+
* composed prompt IS the session record. `stripInlinedTextAttachments` removes
|
|
262
|
+
* those blocks for display, which also throws the names away — so a reloaded
|
|
263
|
+
* session showed no chip for a dropped `.md`/`.py`/`.json` while its image
|
|
264
|
+
* siblings survived (images ride as their own content block).
|
|
265
|
+
*
|
|
266
|
+
* This walks the same format back out, so the timeline can render the chips and
|
|
267
|
+
* re-open the files. The fence length is whatever `fenceForContent` chose (at
|
|
268
|
+
* least 3 backticks, longer when the content itself contains a run), and a
|
|
269
|
+
* block's content may contain anything — including the literal string
|
|
270
|
+
* "Attached file:" — so the closing fence is matched by exact length rather
|
|
271
|
+
* than by looking for the next header.
|
|
272
|
+
*/
|
|
273
|
+
export function extractInlinedTextAttachments(text: string): InlinedTextAttachment[] {
|
|
274
|
+
const attachments: InlinedTextAttachment[] = [];
|
|
275
|
+
const header = /(?:^|\n{2})Attached file: ([^\n]+)\n(`{3,})[a-zA-Z0-9_-]*\n/g;
|
|
276
|
+
let match: RegExpExecArray | null;
|
|
277
|
+
while ((match = header.exec(text)) !== null) {
|
|
278
|
+
const name = match[1].trim();
|
|
279
|
+
const fence = match[2];
|
|
280
|
+
const contentStart = match.index + match[0].length;
|
|
281
|
+
// The closing fence sits on its own line and repeats the opening run
|
|
282
|
+
// exactly; a longer run is content, not the close.
|
|
283
|
+
const closer = `\n${fence}`;
|
|
284
|
+
let cursor = contentStart;
|
|
285
|
+
let end = -1;
|
|
286
|
+
while (cursor <= text.length) {
|
|
287
|
+
const at = text.indexOf(closer, cursor);
|
|
288
|
+
if (at === -1) break;
|
|
289
|
+
const after = text.slice(at + closer.length);
|
|
290
|
+
// Accept the close only at a block boundary: end of string, or a blank
|
|
291
|
+
// line followed by the next header / nothing.
|
|
292
|
+
if (after === '' || after.startsWith('\n') || /^\n{2}Attached file: /.test(after)) {
|
|
293
|
+
end = at;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
cursor = at + closer.length;
|
|
297
|
+
}
|
|
298
|
+
if (end === -1) continue;
|
|
299
|
+
attachments.push({ name, content: text.slice(contentStart, end) });
|
|
300
|
+
header.lastIndex = end + closer.length;
|
|
301
|
+
}
|
|
302
|
+
return attachments;
|
|
303
|
+
}
|
|
@@ -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
|
+
}
|