@pinagent/react-native 0.2.4 → 0.2.5
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/native/AgentDock.d.ts +32 -0
- package/dist/native/MarkdownView.d.ts +24 -0
- package/dist/native/Pinagent.d.ts +5 -4
- package/dist/native/StreamSheet.d.ts +18 -13
- package/dist/native/inspector.d.ts +24 -9
- package/dist/native/keyboard-height.d.ts +2 -0
- package/dist/native/keyboard.d.ts +35 -0
- package/dist/native/markdown.d.ts +65 -0
- package/dist/native/run-state.d.ts +120 -0
- package/dist/native/scroll-follow.d.ts +32 -0
- package/dist/native/ws-client.d.ts +8 -2
- package/package.json +1 -1
- package/src/native/AgentDock.tsx +232 -0
- package/src/native/MarkdownView.tsx +154 -0
- package/src/native/Pinagent.tsx +80 -58
- package/src/native/StreamSheet.tsx +188 -135
- package/src/native/inspector.ts +70 -31
- package/src/native/keyboard-height.ts +34 -0
- package/src/native/keyboard.ts +40 -0
- package/src/native/markdown.ts +194 -0
- package/src/native/run-state.ts +204 -0
- package/src/native/scroll-follow.ts +47 -0
- package/src/native/ws-client.ts +13 -5
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyboard-shortcut helpers for the React Native widget.
|
|
5
|
+
*
|
|
6
|
+
* The browser widget wires its hotkeys to `document`-level `keydown`
|
|
7
|
+
* listeners (see packages/widget/src/keyboard.ts). React Native has no such
|
|
8
|
+
* global key stream in JS: the core `Keyboard` module only reports the soft
|
|
9
|
+
* keyboard showing/hiding, never key presses, and there's no document to
|
|
10
|
+
* listen on. A faithful port of the *global* web hotkeys (toggle picker,
|
|
11
|
+
* hop-to-next-agent, minimize-all) would need a native module — iOS
|
|
12
|
+
* `UIKeyCommand` / an Android key bridge — which we deliberately avoid: the RN
|
|
13
|
+
* widget ships as plain JS source for Metro with zero native setup.
|
|
14
|
+
*
|
|
15
|
+
* What IS reachable, and all we need in practice, are the key events a focused
|
|
16
|
+
* `TextInput` surfaces — so the shortcuts live where a hardware keyboard is
|
|
17
|
+
* actually in play (the composer and the stream sheet, both modal):
|
|
18
|
+
* - Return/Enter, via `onSubmitEditing` on the single-line inputs — submits
|
|
19
|
+
* the agent answer and the follow-up, mirroring the web composer's
|
|
20
|
+
* Enter-to-send. Wired directly on the input (no key inspection needed).
|
|
21
|
+
* - Escape, via `onKeyPress` — backs out of a sheet, mirroring the web
|
|
22
|
+
* widget's Escape. Decided here so the (RN-runtime-only, untestable)
|
|
23
|
+
* components stay thin and the rule is unit-tested.
|
|
24
|
+
*
|
|
25
|
+
* `onKeyPress`'s `nativeEvent` only carries `key` on native platforms (no
|
|
26
|
+
* modifier flags), so these predicates take the bare key name — there's no
|
|
27
|
+
* Shift/Cmd to branch on, which is also why plain Enter stays a newline in the
|
|
28
|
+
* multiline composer rather than hijacking submit. Hardware Back (Android) is
|
|
29
|
+
* handled separately by each Modal's `onRequestClose`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Should this key event back out of the current sheet? Escape on a hardware
|
|
34
|
+
* keyboard — the RN analog of the web widget's Escape. Only the Escape key
|
|
35
|
+
* qualifies; every printable key (i.e. normal typing) is ignored, so an
|
|
36
|
+
* `onKeyPress` handler built on this never interferes with text entry.
|
|
37
|
+
*/
|
|
38
|
+
export function isDismissKey(key: string): boolean {
|
|
39
|
+
return key === 'Escape';
|
|
40
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Tiny, dependency-free Markdown parser for the RN widget.
|
|
4
|
+
*
|
|
5
|
+
* The agent streams its replies as Markdown — Claude writes **bold**, `code`,
|
|
6
|
+
* fenced blocks, bullet/numbered lists, headings and links. The StreamSheet
|
|
7
|
+
* used to drop that straight into a <Text>, so the markers showed up as
|
|
8
|
+
* literal characters. This module folds the raw text into a small block/inline
|
|
9
|
+
* tree that `Markdown.tsx` renders with React Native primitives — no
|
|
10
|
+
* markdown-it, no extra runtime dependency, in keeping with the rest of the
|
|
11
|
+
* native source (see transcript.ts for the same "mirror, don't import" stance).
|
|
12
|
+
*
|
|
13
|
+
* It is intentionally a *subset* of CommonMark covering what shows up in chat:
|
|
14
|
+
* ATX headings, fenced code, blockquotes, unordered/ordered lists, thematic
|
|
15
|
+
* breaks, paragraphs, and inline code / bold / italic / links. Anything it
|
|
16
|
+
* doesn't recognise falls through as plain text, so the worst case degrades to
|
|
17
|
+
* the old behaviour rather than dropping content. Pure and deterministic, so
|
|
18
|
+
* it's unit-tested like the transcript reducer.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** An inline run carrying at most the marks we render. */
|
|
22
|
+
export interface InlineSpan {
|
|
23
|
+
text: string;
|
|
24
|
+
bold?: boolean;
|
|
25
|
+
italic?: boolean;
|
|
26
|
+
code?: boolean;
|
|
27
|
+
/** Present on link spans; the destination URL. */
|
|
28
|
+
href?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type MdBlock =
|
|
32
|
+
| { type: 'heading'; level: number; spans: InlineSpan[] }
|
|
33
|
+
| { type: 'paragraph'; spans: InlineSpan[] }
|
|
34
|
+
| { type: 'code'; text: string; lang?: string }
|
|
35
|
+
| { type: 'list'; ordered: boolean; items: InlineSpan[][] }
|
|
36
|
+
| { type: 'quote'; spans: InlineSpan[] }
|
|
37
|
+
| { type: 'hr' };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Inline scanner: walk the string and, at each step, take the earliest of the
|
|
41
|
+
* recognised markers — ties broken by priority (code > link > bold > italic),
|
|
42
|
+
* so `**x**` reads as one bold run rather than two italics, and a backtick span
|
|
43
|
+
* is never re-parsed for emphasis. Text between markers is emitted verbatim;
|
|
44
|
+
* an unbalanced marker (a lone `*`) never matches and stays literal, so we
|
|
45
|
+
* never swallow content.
|
|
46
|
+
*/
|
|
47
|
+
export function parseInline(input: string): InlineSpan[] {
|
|
48
|
+
const matchers: Array<{ re: RegExp; make: (m: RegExpExecArray) => InlineSpan }> = [
|
|
49
|
+
{ re: /`([^`]+)`/, make: (m) => ({ text: m[1] ?? '', code: true }) },
|
|
50
|
+
{ re: /\[([^\]]+)\]\(([^)\s]+)\)/, make: (m) => ({ text: m[1] ?? '', href: m[2] ?? '' }) },
|
|
51
|
+
{ re: /\*\*([^*]+)\*\*/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
|
|
52
|
+
{ re: /__([^_]+)__/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
|
|
53
|
+
{ re: /\*([^*]+)\*/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
|
|
54
|
+
{ re: /_([^_]+)_/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const spans: InlineSpan[] = [];
|
|
58
|
+
let rest = input;
|
|
59
|
+
while (rest.length > 0) {
|
|
60
|
+
let best: { index: number; length: number; span: InlineSpan } | null = null;
|
|
61
|
+
for (const { re, make } of matchers) {
|
|
62
|
+
const m = re.exec(rest);
|
|
63
|
+
// Strictly-less keeps the higher-priority matcher on an index tie.
|
|
64
|
+
if (m && (best === null || m.index < best.index)) {
|
|
65
|
+
best = { index: m.index, length: (m[0] ?? '').length, span: make(m) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (best === null) {
|
|
69
|
+
pushText(spans, rest);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
pushText(spans, rest.slice(0, best.index));
|
|
73
|
+
spans.push(best.span);
|
|
74
|
+
rest = rest.slice(best.index + best.length);
|
|
75
|
+
}
|
|
76
|
+
return spans;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushText(spans: InlineSpan[], text: string): void {
|
|
80
|
+
if (text) spans.push({ text });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fold raw Markdown into render-ready blocks. Line-oriented and greedy:
|
|
85
|
+
* consecutive list items fold into one list, consecutive `>` lines into one
|
|
86
|
+
* quote, and consecutive plain lines into one paragraph (soft-wrapped lines are
|
|
87
|
+
* joined with a space). Blank lines separate paragraphs. Pure and
|
|
88
|
+
* deterministic.
|
|
89
|
+
*/
|
|
90
|
+
export function parseMarkdown(source: string): MdBlock[] {
|
|
91
|
+
const blocks: MdBlock[] = [];
|
|
92
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
93
|
+
|
|
94
|
+
let paragraph: string[] = [];
|
|
95
|
+
function flushParagraph(): void {
|
|
96
|
+
const text = paragraph.join(' ').trim();
|
|
97
|
+
paragraph = [];
|
|
98
|
+
if (text) blocks.push({ type: 'paragraph', spans: parseInline(text) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < lines.length) {
|
|
103
|
+
const line = lines[i] ?? '';
|
|
104
|
+
|
|
105
|
+
// Fenced code block: ``` or ```lang … until a closing ``` (or EOF).
|
|
106
|
+
const fence = /^\s*```(.*)$/.exec(line);
|
|
107
|
+
if (fence) {
|
|
108
|
+
flushParagraph();
|
|
109
|
+
const lang = (fence[1] ?? '').trim();
|
|
110
|
+
const body: string[] = [];
|
|
111
|
+
i++;
|
|
112
|
+
while (i < lines.length) {
|
|
113
|
+
const l = lines[i] ?? '';
|
|
114
|
+
if (/^\s*```\s*$/.test(l)) break;
|
|
115
|
+
body.push(l);
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
i++; // consume the closing fence (no-op at EOF)
|
|
119
|
+
blocks.push({ type: 'code', text: body.join('\n'), ...(lang ? { lang } : {}) });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Blank line: end the current paragraph.
|
|
124
|
+
if (/^\s*$/.test(line)) {
|
|
125
|
+
flushParagraph();
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Thematic break: a line of 3+ identical -, * or _.
|
|
131
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
132
|
+
flushParagraph();
|
|
133
|
+
blocks.push({ type: 'hr' });
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ATX heading (#–######), trailing #'s stripped.
|
|
139
|
+
const heading = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
140
|
+
if (heading) {
|
|
141
|
+
flushParagraph();
|
|
142
|
+
blocks.push({
|
|
143
|
+
type: 'heading',
|
|
144
|
+
level: (heading[1] ?? '').length,
|
|
145
|
+
spans: parseInline(heading[2] ?? ''),
|
|
146
|
+
});
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Blockquote: fold consecutive `>` lines together.
|
|
152
|
+
if (/^\s*>/.test(line)) {
|
|
153
|
+
flushParagraph();
|
|
154
|
+
const quoted: string[] = [];
|
|
155
|
+
while (i < lines.length) {
|
|
156
|
+
const l = lines[i] ?? '';
|
|
157
|
+
if (!/^\s*>/.test(l)) break;
|
|
158
|
+
quoted.push(l.replace(/^\s*>\s?/, ''));
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
blocks.push({ type: 'quote', spans: parseInline(quoted.join(' ').trim()) });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// List: fold consecutive items of the same kind (ordered vs unordered).
|
|
166
|
+
const first = matchListItem(line);
|
|
167
|
+
if (first) {
|
|
168
|
+
flushParagraph();
|
|
169
|
+
const items: InlineSpan[][] = [];
|
|
170
|
+
while (i < lines.length) {
|
|
171
|
+
const it = matchListItem(lines[i] ?? '');
|
|
172
|
+
if (!it || it.ordered !== first.ordered) break;
|
|
173
|
+
items.push(parseInline(it.text));
|
|
174
|
+
i++;
|
|
175
|
+
}
|
|
176
|
+
blocks.push({ type: 'list', ordered: first.ordered, items });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Otherwise accumulate into the running paragraph.
|
|
181
|
+
paragraph.push(line.trim());
|
|
182
|
+
i++;
|
|
183
|
+
}
|
|
184
|
+
flushParagraph();
|
|
185
|
+
return blocks;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchListItem(line: string): { ordered: boolean; text: string } | null {
|
|
189
|
+
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
190
|
+
if (ul) return { ordered: false, text: ul[1] ?? '' };
|
|
191
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
192
|
+
if (ol) return { ordered: true, text: ol[1] ?? '' };
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure run-state model for the RN widget's live agent runs.
|
|
4
|
+
*
|
|
5
|
+
* The minimized agent UI used to derive its status ad-hoc inside `StreamSheet`
|
|
6
|
+
* from a tangle of booleans (`running`/`done`/`transportError`/`askOpen`),
|
|
7
|
+
* encoded purely by dot color. This module replaces that with one explicit,
|
|
8
|
+
* unit-testable state machine: a {@link RunState} per run, a presentation map
|
|
9
|
+
* (glyph + tone + label, not color-alone), and the aggregation
|
|
10
|
+
* ({@link dockModel}) the compact bottom-left dock renders.
|
|
11
|
+
*
|
|
12
|
+
* It's deliberately dependency-free (only the sibling `transcript` reducer) so
|
|
13
|
+
* it's testable without React Native — the device UI (`AgentDock`, the expanded
|
|
14
|
+
* `StreamSheet`) stays a thin renderer over these pure functions, the same way
|
|
15
|
+
* `transcript.ts` / `restore.ts` keep their logic out of the un-testable
|
|
16
|
+
* RN-runtime layer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type AgentEvent, pendingAsk } from './transcript';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The lifecycle state of one streamed agent run.
|
|
23
|
+
*
|
|
24
|
+
* - `connecting` — socket up, transcript not replayed yet (no events). The
|
|
25
|
+
* brief window after spawn/restore and again right after a reconnect.
|
|
26
|
+
* - `working` — events are flowing and the run hasn't ended.
|
|
27
|
+
* - `awaiting` — blocked on an `ask_user` the developer hasn't answered. The
|
|
28
|
+
* one state that needs the developer's attention; it pulses.
|
|
29
|
+
* - `done` — ended cleanly (success result, or the bus simply closed).
|
|
30
|
+
* - `failed` — ended on an error (server error frame, transport error, or a
|
|
31
|
+
* non-success result subtype).
|
|
32
|
+
*/
|
|
33
|
+
export type RunState = 'connecting' | 'working' | 'awaiting' | 'done' | 'failed';
|
|
34
|
+
|
|
35
|
+
export interface RunStateInput {
|
|
36
|
+
/** The folded agent event stream (same array `StreamSheet` accumulates). */
|
|
37
|
+
events: AgentEvent[];
|
|
38
|
+
/** Set once the run reached a terminal `result`/`done`. */
|
|
39
|
+
done: boolean;
|
|
40
|
+
/** Non-null when a server `error` frame (or "no dev server") arrived. */
|
|
41
|
+
transportError: string | null;
|
|
42
|
+
/** `askId` → answer, so an answered question no longer reads as `awaiting`. */
|
|
43
|
+
answered: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Did the run's last terminal event signal failure rather than success? */
|
|
47
|
+
function endedInFailure(events: AgentEvent[]): boolean {
|
|
48
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
49
|
+
const e = events[i];
|
|
50
|
+
if (!e) continue;
|
|
51
|
+
if (e.type === 'error') return true;
|
|
52
|
+
if (e.type === 'result') return e.subtype !== 'success';
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collapse the raw run booleans into a single {@link RunState}. Pure and
|
|
59
|
+
* order-sensitive: a transport error wins over everything (you can't answer or
|
|
60
|
+
* keep working over a dead socket), then an unanswered question, then terminal
|
|
61
|
+
* done/failure, else working-vs-connecting by whether any event has arrived.
|
|
62
|
+
*/
|
|
63
|
+
export function deriveRunState({
|
|
64
|
+
events,
|
|
65
|
+
done,
|
|
66
|
+
transportError,
|
|
67
|
+
answered,
|
|
68
|
+
}: RunStateInput): RunState {
|
|
69
|
+
if (transportError) return 'failed';
|
|
70
|
+
const ask = pendingAsk(events);
|
|
71
|
+
if (ask && !answered[ask.askId]) return 'awaiting';
|
|
72
|
+
if (done) return endedInFailure(events) ? 'failed' : 'done';
|
|
73
|
+
return events.length > 0 ? 'working' : 'connecting';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Semantic color bucket; mapped to concrete colors in the component layer. */
|
|
77
|
+
export type RunTone = 'neutral' | 'active' | 'attention' | 'success' | 'danger';
|
|
78
|
+
|
|
79
|
+
export interface RunPresentation {
|
|
80
|
+
/** Short status word for labels / accessibility. */
|
|
81
|
+
label: string;
|
|
82
|
+
/** Monochrome glyph so state never rides on color alone. */
|
|
83
|
+
glyph: string;
|
|
84
|
+
/** Semantic tone the renderer maps to a palette entry. */
|
|
85
|
+
tone: RunTone;
|
|
86
|
+
/** Whether the chip should pulse to pull the developer back. */
|
|
87
|
+
pulse: boolean;
|
|
88
|
+
/** Non-terminal (connecting/working/awaiting) — i.e. still an active run. */
|
|
89
|
+
active: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PRESENTATION: Record<RunState, RunPresentation> = {
|
|
93
|
+
connecting: { label: 'Connecting', glyph: '◌', tone: 'neutral', pulse: false, active: true },
|
|
94
|
+
working: { label: 'Working', glyph: '◐', tone: 'active', pulse: false, active: true },
|
|
95
|
+
awaiting: { label: 'Needs you', glyph: '!', tone: 'attention', pulse: true, active: true },
|
|
96
|
+
done: { label: 'Done', glyph: '✓', tone: 'success', pulse: false, active: false },
|
|
97
|
+
failed: { label: 'Failed', glyph: '✕', tone: 'danger', pulse: false, active: false },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Does an "interrupting" overlay actually apply right now?
|
|
102
|
+
*
|
|
103
|
+
* Stop is purely a client-side affordance over the existing `interrupt` frame
|
|
104
|
+
* (ticket 015): tapping it sets a local flag, and we keep showing "Stopping…"
|
|
105
|
+
* until the server lands a terminal event. The overlay therefore applies only
|
|
106
|
+
* while the run is still {@link RunPresentation.active active} — once it reaches
|
|
107
|
+
* a terminal `done`/`failed` state the interrupt resolved, so the overlay drops
|
|
108
|
+
* even if the caller's flag hasn't been cleared yet. Modeling it as an overlay
|
|
109
|
+
* (rather than a sixth {@link RunState}) keeps the state machine — and the dock
|
|
110
|
+
* aggregation — unchanged and unit-testable.
|
|
111
|
+
*/
|
|
112
|
+
export function interruptOverlayActive(state: RunState, interrupting: boolean): boolean {
|
|
113
|
+
return interrupting && PRESENTATION[state].active;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Presentation for a run, optionally overlaid with an interrupting affordance.
|
|
118
|
+
*
|
|
119
|
+
* With `interrupting` true on a still-active run we relabel to "Stopping…" and
|
|
120
|
+
* stop any pulse (the developer asked it to halt — pulsing for attention is the
|
|
121
|
+
* wrong signal), keeping the underlying state's glyph/tone/active so the dock
|
|
122
|
+
* partitioning is undisturbed. Terminal states ignore the flag (see
|
|
123
|
+
* {@link interruptOverlayActive}).
|
|
124
|
+
*/
|
|
125
|
+
export function runPresentation(state: RunState, interrupting = false): RunPresentation {
|
|
126
|
+
const base = PRESENTATION[state];
|
|
127
|
+
if (!interruptOverlayActive(state, interrupting)) return base;
|
|
128
|
+
return { ...base, label: 'Stopping…', pulse: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A run as the dock sees it: identity, header label, and derived state. */
|
|
132
|
+
export interface DockRun {
|
|
133
|
+
id: string;
|
|
134
|
+
/** Header label — `file:line` if anchored, else the component name. */
|
|
135
|
+
target: string;
|
|
136
|
+
state: RunState;
|
|
137
|
+
/**
|
|
138
|
+
* The developer tapped Stop and we're awaiting the run's terminal event
|
|
139
|
+
* (ticket 015). An overlay over `state`, not a state — the chip relabels to
|
|
140
|
+
* "Stopping…" while it's still active. Ignored once terminal.
|
|
141
|
+
*/
|
|
142
|
+
interrupting?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DockModel {
|
|
146
|
+
/** connecting/working/awaiting, sorted attention-first (stable within tone). */
|
|
147
|
+
active: DockRun[];
|
|
148
|
+
/** done/failed, in input order. */
|
|
149
|
+
finished: DockRun[];
|
|
150
|
+
/** Collapse the active runs into a single count bar (true at ≥2 active). */
|
|
151
|
+
collapseActive: boolean;
|
|
152
|
+
/** How many active runs are blocked on input. */
|
|
153
|
+
awaitingCount: number;
|
|
154
|
+
/** State driving the collapsed bar's glyph/tone (most attention-worthy). */
|
|
155
|
+
summaryState: RunState;
|
|
156
|
+
/** Bar text, e.g. `3 agents · 1 needs you`. */
|
|
157
|
+
activeHeadline: string;
|
|
158
|
+
/** True when any finished run failed (lets the summary flag failures). */
|
|
159
|
+
finishedHasFailure: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Attention priority for ordering/summarizing active runs (higher = first). */
|
|
163
|
+
const ACTIVE_PRIORITY: Record<RunState, number> = {
|
|
164
|
+
awaiting: 3,
|
|
165
|
+
working: 2,
|
|
166
|
+
connecting: 1,
|
|
167
|
+
done: 0,
|
|
168
|
+
failed: 0,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Aggregate runs into what the dock draws. Active runs surface attention-first
|
|
173
|
+
* (a blocked run jumps above a busy one); ≥2 of them collapse into one count
|
|
174
|
+
* bar. Finished runs always roll into a `▸ N finished` summary. Pure so the
|
|
175
|
+
* hybrid + done-summary behavior is covered without rendering anything.
|
|
176
|
+
*/
|
|
177
|
+
export function dockModel(runs: readonly DockRun[]): DockModel {
|
|
178
|
+
// Stable attention-first sort for active runs: decorate with the input index
|
|
179
|
+
// so equal-tone runs keep their arrival order.
|
|
180
|
+
const active = runs
|
|
181
|
+
.filter((r) => runPresentation(r.state).active)
|
|
182
|
+
.map((run, i) => ({ run, i }))
|
|
183
|
+
.sort((a, b) => ACTIVE_PRIORITY[b.run.state] - ACTIVE_PRIORITY[a.run.state] || a.i - b.i)
|
|
184
|
+
.map(({ run }) => run);
|
|
185
|
+
const finished = runs.filter((r) => !runPresentation(r.state).active);
|
|
186
|
+
|
|
187
|
+
const awaitingCount = active.filter((r) => r.state === 'awaiting').length;
|
|
188
|
+
const summaryState = active[0]?.state ?? 'working';
|
|
189
|
+
const noun = active.length === 1 ? 'agent' : 'agents';
|
|
190
|
+
const activeHeadline =
|
|
191
|
+
awaitingCount > 0
|
|
192
|
+
? `${active.length} ${noun} · ${awaitingCount} needs you`
|
|
193
|
+
: `${active.length} ${noun}`;
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
active,
|
|
197
|
+
finished,
|
|
198
|
+
collapseActive: active.length >= 2,
|
|
199
|
+
awaitingCount,
|
|
200
|
+
summaryState,
|
|
201
|
+
activeHeadline,
|
|
202
|
+
finishedHasFailure: finished.some((r) => r.state === 'failed'),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure scroll-follow predicate for the RN widget's live transcript.
|
|
4
|
+
*
|
|
5
|
+
* The expanded `StreamSheet` transcript used to call `scrollToEnd` on every
|
|
6
|
+
* content change, so scrolling up to re-read earlier output during an active run
|
|
7
|
+
* got yanked back to the bottom by the next event. The fix is the standard
|
|
8
|
+
* chat-log "stick to bottom only while already near the bottom" behavior — and
|
|
9
|
+
* the near-bottom test is the one piece of math worth pulling out of the
|
|
10
|
+
* un-testable RN-runtime layer (mirrors how `run-state.ts` / `transcript.ts`
|
|
11
|
+
* keep their logic pure, see packages/react-native/src/native).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Default slack (in px) for treating "almost at the bottom" as "at bottom". */
|
|
15
|
+
export const NEAR_BOTTOM_THRESHOLD = 24;
|
|
16
|
+
|
|
17
|
+
export interface NearBottomInput {
|
|
18
|
+
/** `contentOffset.y` — how far the content is scrolled up from the top. */
|
|
19
|
+
offsetY: number;
|
|
20
|
+
/** `layoutMeasurement.height` — the visible viewport height. */
|
|
21
|
+
viewportH: number;
|
|
22
|
+
/** `contentSize.height` — the full scrollable content height. */
|
|
23
|
+
contentH: number;
|
|
24
|
+
/** Slack in px; the gap to the bottom that still counts as "at bottom". */
|
|
25
|
+
threshold?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when the scroll position is within `threshold` px of the bottom (so
|
|
30
|
+
* auto-follow should keep pinning new content into view). Pure and tolerant of
|
|
31
|
+
* the bouncy/over-scroll values RN can report: a negative remaining distance
|
|
32
|
+
* (rubber-band past the end) still reads as at-bottom, and content shorter than
|
|
33
|
+
* the viewport is always at-bottom. A non-finite or non-positive threshold
|
|
34
|
+
* falls back to {@link NEAR_BOTTOM_THRESHOLD}.
|
|
35
|
+
*/
|
|
36
|
+
export function isNearBottom({
|
|
37
|
+
offsetY,
|
|
38
|
+
viewportH,
|
|
39
|
+
contentH,
|
|
40
|
+
threshold = NEAR_BOTTOM_THRESHOLD,
|
|
41
|
+
}: NearBottomInput): boolean {
|
|
42
|
+
const slack = Number.isFinite(threshold) && threshold > 0 ? threshold : NEAR_BOTTOM_THRESHOLD;
|
|
43
|
+
// Content that doesn't fill the viewport can't be scrolled — always pinned.
|
|
44
|
+
if (contentH <= viewportH) return true;
|
|
45
|
+
const distanceFromBottom = contentH - (offsetY + viewportH);
|
|
46
|
+
return distanceFromBottom <= slack;
|
|
47
|
+
}
|
package/src/native/ws-client.ts
CHANGED
|
@@ -84,9 +84,14 @@ export class StreamClient {
|
|
|
84
84
|
this.send({ type: 'ask_response', askId, answer });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Interrupt the in-flight run. Returns whether the frame was actually written
|
|
89
|
+
* to an OPEN socket — `false` means it was dropped (socket closed/reconnecting)
|
|
90
|
+
* so the UI can say "couldn't stop" instead of silently no-opping. The server
|
|
91
|
+
* tears the run down on its own terminal event; this is just the request.
|
|
92
|
+
*/
|
|
93
|
+
interrupt(): boolean {
|
|
94
|
+
return this.send({ type: 'interrupt', feedbackId: this.feedbackId });
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
private connect(): void {
|
|
@@ -151,13 +156,16 @@ export class StreamClient {
|
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
|
|
159
|
+
/** Write a frame if the socket is OPEN. Returns whether it was sent. */
|
|
160
|
+
private send(msg: Record<string, unknown>): boolean {
|
|
155
161
|
const s = this.socket;
|
|
156
|
-
if (!s || s.readyState !== 1 /* OPEN */) return;
|
|
162
|
+
if (!s || s.readyState !== 1 /* OPEN */) return false;
|
|
157
163
|
try {
|
|
158
164
|
s.send(JSON.stringify(msg));
|
|
165
|
+
return true;
|
|
159
166
|
} catch {
|
|
160
167
|
// socket closing mid-write
|
|
168
|
+
return false;
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
|