bunnyquery 1.6.2 → 1.7.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/README.md +157 -29
- package/bunnyquery.css +122 -0
- package/bunnyquery.js +998 -116
- package/dist/engine.cjs +724 -39
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +386 -4
- package/dist/engine.d.ts +386 -4
- package/dist/engine.mjs +712 -40
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +20 -2
- package/src/engine/host.ts +28 -1
- package/src/engine/index.ts +24 -1
- package/src/engine/indexing_groups.ts +350 -0
- package/src/engine/links.ts +309 -9
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +394 -30
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +122 -0
package/package.json
CHANGED
package/src/engine/history.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type MapHistoryOptions = {
|
|
|
44
44
|
clearedAt: number;
|
|
45
45
|
serviceId: string;
|
|
46
46
|
/** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
|
|
47
|
-
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string) => string;
|
|
47
|
+
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean) => string;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions) {
|
|
@@ -67,18 +67,35 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
67
67
|
|
|
68
68
|
if (userText) {
|
|
69
69
|
var displayContent;
|
|
70
|
+
// Structured file ref for the display layer's per-file collapsing, kept
|
|
71
|
+
// alongside the formatted label so grouping never has to parse it back.
|
|
72
|
+
var indexFile: any = undefined;
|
|
70
73
|
if (item._isBgTask) {
|
|
71
74
|
var nameMatch = userText.match(/^- name: (.+)$/m);
|
|
72
75
|
if (nameMatch) {
|
|
73
76
|
var mimeMatch = userText.match(/^- mime type: (.+)$/m);
|
|
74
77
|
var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
|
|
75
78
|
var pathMatch = userText.match(/^- storage path: (.+)$/m);
|
|
79
|
+
// A CONTINUE pass ("CONTINUE indexing …") gets the compact
|
|
80
|
+
// continuation label; a first pass ("A new file …") gets the full
|
|
81
|
+
// one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
|
|
82
|
+
// windows read as progress, not the same task repeating.
|
|
83
|
+
var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
|
|
76
84
|
displayContent = opts.formatIndexingLabel(
|
|
77
85
|
nameMatch[1].trim(),
|
|
78
86
|
mimeMatch ? mimeMatch[1].trim() : '',
|
|
79
87
|
sizeMatch ? Number(sizeMatch[1]) : null,
|
|
80
|
-
pathMatch ? pathMatch[1].trim() : undefined
|
|
88
|
+
pathMatch ? pathMatch[1].trim() : undefined,
|
|
89
|
+
false,
|
|
90
|
+
isContinuePass
|
|
81
91
|
);
|
|
92
|
+
indexFile = {
|
|
93
|
+
name: nameMatch[1].trim(),
|
|
94
|
+
path: pathMatch ? pathMatch[1].trim() : undefined,
|
|
95
|
+
mime: mimeMatch ? mimeMatch[1].trim() : undefined,
|
|
96
|
+
size: sizeMatch ? Number(sizeMatch[1]) : undefined,
|
|
97
|
+
continued: isContinuePass,
|
|
98
|
+
};
|
|
82
99
|
} else {
|
|
83
100
|
displayContent = userText;
|
|
84
101
|
}
|
|
@@ -90,6 +107,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
90
107
|
if (isQueued) userMsg.isPendingQueued = true;
|
|
91
108
|
if (isCancelledItem) userMsg.isCancelled = true;
|
|
92
109
|
if (item._isBgTask) userMsg.isBackgroundTask = true;
|
|
110
|
+
if (indexFile) userMsg._indexFile = indexFile;
|
|
93
111
|
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
94
112
|
if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
|
|
95
113
|
mapped.push(userMsg);
|
package/src/engine/host.ts
CHANGED
|
@@ -30,6 +30,25 @@ export interface PinnedDispatchContext {
|
|
|
30
30
|
systemPrompt: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The file a background-indexing bubble belongs to. Stamped on the REQUEST
|
|
35
|
+
* bubble (both the live one and the one rebuilt from history) so the display
|
|
36
|
+
* layer can group a file's many passes without reverse-parsing the view's
|
|
37
|
+
* formatted label. See indexing_groups.buildChatDisplayList.
|
|
38
|
+
*/
|
|
39
|
+
export interface IndexingFileRef {
|
|
40
|
+
name: string;
|
|
41
|
+
/** Storage path, when known. Preferred group identity: a file can be
|
|
42
|
+
* re-uploaded under a name that already exists elsewhere. */
|
|
43
|
+
path?: string;
|
|
44
|
+
mime?: string;
|
|
45
|
+
size?: number;
|
|
46
|
+
isReindex?: boolean;
|
|
47
|
+
/** A CONTINUE pass. Its absence across every loaded pass is what tells the
|
|
48
|
+
* display layer that earlier passes are still unpaged. */
|
|
49
|
+
continued?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
export interface ChatMessage {
|
|
34
53
|
role: 'user' | 'assistant';
|
|
35
54
|
content: string; // raw markdown — never HTML (the view parses for display)
|
|
@@ -41,6 +60,8 @@ export interface ChatMessage {
|
|
|
41
60
|
isCancelled?: boolean;
|
|
42
61
|
isError?: boolean;
|
|
43
62
|
isBackgroundTask?: boolean;
|
|
63
|
+
/** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
|
|
64
|
+
_indexFile?: IndexingFileRef;
|
|
44
65
|
_useBgQueue?: boolean;
|
|
45
66
|
_serverItemId?: string;
|
|
46
67
|
_localId?: string;
|
|
@@ -86,6 +107,12 @@ export interface ChatHost {
|
|
|
86
107
|
scrollToBottom(smooth?: boolean): Promise<void> | void;
|
|
87
108
|
/** Scroll only if the user is pinned to the bottom (does not force-pin). */
|
|
88
109
|
scrollToBottomIfSticky(smooth?: boolean): Promise<void> | void;
|
|
110
|
+
/** A history page finished loading and rendering. OPTIONAL. The view uses it
|
|
111
|
+
* to page further when the message box came out too short to scroll — the
|
|
112
|
+
* only trigger for loading older history is a scroll to the top, so a box
|
|
113
|
+
* that cannot scroll has no way to reach page 2 (see viewport_fill). Only
|
|
114
|
+
* the view can measure that, which is why the engine merely announces it. */
|
|
115
|
+
onHistoryLoaded?(fetchMore: boolean, token: number): void;
|
|
89
116
|
|
|
90
117
|
// --- skapi surface beyond configureChatEngine() ---
|
|
91
118
|
cancelRequest(opts: {
|
|
@@ -95,7 +122,7 @@ export interface ChatHost {
|
|
|
95
122
|
|
|
96
123
|
// --- bg-indexing display ---
|
|
97
124
|
/** Build the "Indexing:/Reindexing: …" label (view-side display formatting). */
|
|
98
|
-
formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean): string;
|
|
125
|
+
formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean): string;
|
|
99
126
|
/** drainBgTaskQueue is a no-op until the chat view is mounted. */
|
|
100
127
|
isViewMounted(): boolean;
|
|
101
128
|
|
package/src/engine/index.ts
CHANGED
|
@@ -54,10 +54,33 @@ export {
|
|
|
54
54
|
type MapHistoryOptions,
|
|
55
55
|
} from './history';
|
|
56
56
|
|
|
57
|
+
// Older history is reachable only by scrolling to the top of the message box, so
|
|
58
|
+
// a box too short to scroll strands the user on page 1 — the normal state once a
|
|
59
|
+
// page of history collapses into one indexing row. Shared so both chatboxes page
|
|
60
|
+
// their way out of it identically.
|
|
61
|
+
export {
|
|
62
|
+
fillHistoryViewport,
|
|
63
|
+
createHistoryFiller,
|
|
64
|
+
HISTORY_FILL_SLACK_PX,
|
|
65
|
+
MAX_HISTORY_FILL_PAGES,
|
|
66
|
+
type FillHistoryViewportOptions,
|
|
67
|
+
} from './viewport_fill';
|
|
68
|
+
|
|
57
69
|
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
58
70
|
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
59
71
|
export { ChatSession } from './session';
|
|
60
|
-
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
72
|
+
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
|
|
73
|
+
|
|
74
|
+
// Display transform: collapse a file's many background-indexing turns into one
|
|
75
|
+
// row, wherever they sit in the conversation. Shared so both chatboxes match.
|
|
76
|
+
export {
|
|
77
|
+
buildChatDisplayList,
|
|
78
|
+
parseIndexingLabel,
|
|
79
|
+
type DisplayEntry,
|
|
80
|
+
type IndexingGroup,
|
|
81
|
+
type IndexingGroupStatus,
|
|
82
|
+
type BuildDisplayListOptions,
|
|
83
|
+
} from './indexing_groups';
|
|
61
84
|
|
|
62
85
|
export {
|
|
63
86
|
// constants
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background file-indexing turns, collapsed into ONE row per file.
|
|
3
|
+
*
|
|
4
|
+
* A single upload can produce many chat turns: the first "Indexing: <file>" pass
|
|
5
|
+
* plus up to MAX_INDEXING_RESUME_PASSES CONTINUE passes, each with its own
|
|
6
|
+
* request AND response bubble. Rendered flat, that reads as the same task
|
|
7
|
+
* repeating forever, and any real question the user asks in between gets buried.
|
|
8
|
+
*
|
|
9
|
+
* buildChatDisplayList turns the flat message array into a DISPLAY list in which
|
|
10
|
+
* every message belonging to one file (however far apart they sit, and whatever
|
|
11
|
+
* else is interleaved between them) is represented by a single group entry,
|
|
12
|
+
* rendered at the position of that file's NEWEST turn. Newest, not oldest, so:
|
|
13
|
+
* - a running index sits at the bottom, where the activity is, and
|
|
14
|
+
* - paging older history in never moves a row that is already on screen
|
|
15
|
+
* (older passes simply join the group they already belong to).
|
|
16
|
+
*
|
|
17
|
+
* The group deliberately reports no authoritative pass TOTAL. History is paged
|
|
18
|
+
* newest-first, so any total computed from loaded messages is a lower bound that
|
|
19
|
+
* a later scroll-up would contradict. It reports STATE (indexing / indexed /
|
|
20
|
+
* failed), how many passes are currently loaded, and `mayHaveOlder` when the
|
|
21
|
+
* file's first pass is not among them.
|
|
22
|
+
*
|
|
23
|
+
* Pure and view-agnostic: agent.vue and the BunnyQuery widget both render from
|
|
24
|
+
* this, so the two stay identical.
|
|
25
|
+
*/
|
|
26
|
+
import type { ChatMessage, IndexingFileRef } from './host';
|
|
27
|
+
|
|
28
|
+
export type IndexingGroupStatus = 'active' | 'done' | 'error' | 'cancelled';
|
|
29
|
+
|
|
30
|
+
export type IndexingGroup = {
|
|
31
|
+
/** The FILE this row is about: storage path when known (a file can be
|
|
32
|
+
* re-uploaded under a name that already exists elsewhere), else name. Shared
|
|
33
|
+
* by every run of that file, and what ChatSession.cancelIndexingGroup and
|
|
34
|
+
* _indexKeyOf match on — never use it as a render key. */
|
|
35
|
+
key: string;
|
|
36
|
+
/** Identity of this ROW: one indexing RUN of that file. A file indexed on
|
|
37
|
+
* Monday and re-indexed on Wednesday is two runs, and collapsing them into
|
|
38
|
+
* one row erased Monday's from Monday's place in the conversation, claimed
|
|
39
|
+
* its passes for Wednesday, and let Monday's failure be overwritten by
|
|
40
|
+
* Wednesday's success. Numbered from the NEWEST run backwards (`#0` is the
|
|
41
|
+
* newest) so paging in older history never renames a row already on screen.
|
|
42
|
+
* This is the render key and the expansion key. */
|
|
43
|
+
runKey: string;
|
|
44
|
+
name: string;
|
|
45
|
+
path?: string;
|
|
46
|
+
mime?: string;
|
|
47
|
+
size?: number;
|
|
48
|
+
/** True when any loaded pass was a re-index of an already-stored file. */
|
|
49
|
+
isReindex: boolean;
|
|
50
|
+
/** Every message of this file, in chat order, with its index in the source
|
|
51
|
+
* array (so cancel/typewriter paths keep addressing the real message). */
|
|
52
|
+
members: { msg: ChatMessage; index: number }[];
|
|
53
|
+
/** Indexing passes LOADED (request bubbles), never a server-side total. */
|
|
54
|
+
passCount: number;
|
|
55
|
+
status: IndexingGroupStatus;
|
|
56
|
+
/** Server item ids of the passes that are still queued/running, so the row can
|
|
57
|
+
* offer a stop button (ChatSession.cancelIndexingGroup cancels each). Empty
|
|
58
|
+
* when nothing is cancellable — a finished file, or a live pass whose server
|
|
59
|
+
* id has not come back yet. */
|
|
60
|
+
cancellableIds: string[];
|
|
61
|
+
/** A cancel request is in flight for one of the passes. */
|
|
62
|
+
cancelling: boolean;
|
|
63
|
+
/** Why the last cancel attempt failed (e.g. the pass had already finished). */
|
|
64
|
+
cancelError?: string;
|
|
65
|
+
/** The file's first pass is not among the loaded messages, so earlier passes
|
|
66
|
+
* exist in history that has not been paged in yet. */
|
|
67
|
+
mayHaveOlder: boolean;
|
|
68
|
+
/** Position in the source array this collapsed row renders at. */
|
|
69
|
+
anchorIndex: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type DisplayEntry =
|
|
73
|
+
| { kind: 'message'; msg: ChatMessage; index: number }
|
|
74
|
+
| { kind: 'indexing'; group: IndexingGroup; index: number };
|
|
75
|
+
|
|
76
|
+
export type BuildDisplayListOptions = {
|
|
77
|
+
/** True while older history remains unpaged, which is what makes a group
|
|
78
|
+
* with no first pass genuinely incomplete rather than merely odd. */
|
|
79
|
+
hasMoreHistory?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// The indexing label is view-formatted (formatIndexingLabel), so parsing it is
|
|
83
|
+
// the FALLBACK path only: it exists for bubbles restored from a history cache
|
|
84
|
+
// written before `_indexFile` was stamped. Live and freshly-mapped bubbles carry
|
|
85
|
+
// the structured ref and never reach this.
|
|
86
|
+
//
|
|
87
|
+
// Shapes produced by formatIndexingLabel:
|
|
88
|
+
// "Indexing: [name](path) · mime · 1.2 MB"
|
|
89
|
+
// "Reindexing: name · mime"
|
|
90
|
+
// "Indexing (continuing) [name](path)"
|
|
91
|
+
const INDEXING_LABEL_RE = /^(Re)?[Ii]ndexing(\s*\(continuing\))?\s*:?\s+(.+)$/;
|
|
92
|
+
const LEADING_MD_LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/;
|
|
93
|
+
|
|
94
|
+
export function parseIndexingLabel(
|
|
95
|
+
content: string,
|
|
96
|
+
): { name: string; path?: string; continued: boolean; isReindex: boolean } | null {
|
|
97
|
+
if (typeof content !== 'string' || !content) return null;
|
|
98
|
+
var firstLine = content.split('\n')[0].trim();
|
|
99
|
+
var m = firstLine.match(INDEXING_LABEL_RE);
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
// Only the first " · " segment is the file; the rest is mime/size trivia.
|
|
102
|
+
var head = m[3].split(' · ')[0].trim();
|
|
103
|
+
var link = head.match(LEADING_MD_LINK_RE);
|
|
104
|
+
var name = link ? link[1].trim() : head;
|
|
105
|
+
if (!name) return null;
|
|
106
|
+
return {
|
|
107
|
+
name: name,
|
|
108
|
+
path: link ? link[2].trim() : undefined,
|
|
109
|
+
continued: !!m[2],
|
|
110
|
+
isReindex: !!m[1],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The file a background-indexing bubble belongs to, structured ref preferred. */
|
|
115
|
+
function readFileRef(msg: ChatMessage): (IndexingFileRef & { continued: boolean }) | null {
|
|
116
|
+
var ref = msg && msg._indexFile;
|
|
117
|
+
if (ref && (ref.path || ref.name)) {
|
|
118
|
+
return {
|
|
119
|
+
name: ref.name || ref.path || '',
|
|
120
|
+
path: ref.path,
|
|
121
|
+
mime: ref.mime,
|
|
122
|
+
size: ref.size,
|
|
123
|
+
isReindex: ref.isReindex,
|
|
124
|
+
continued: !!ref.continued,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
var parsed = parseIndexingLabel(msg && msg.content);
|
|
128
|
+
if (!parsed) return null;
|
|
129
|
+
return {
|
|
130
|
+
name: parsed.name,
|
|
131
|
+
path: parsed.path,
|
|
132
|
+
isReindex: parsed.isReindex,
|
|
133
|
+
continued: parsed.continued,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isPendingMsg(m: ChatMessage): boolean {
|
|
138
|
+
return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Collapse background-indexing turns into per-file groups.
|
|
143
|
+
*
|
|
144
|
+
* Messages that are not background-indexing pass through untouched, at their
|
|
145
|
+
* original positions and with their original indices.
|
|
146
|
+
*/
|
|
147
|
+
export function buildChatDisplayList(
|
|
148
|
+
messages: ChatMessage[],
|
|
149
|
+
opts?: BuildDisplayListOptions,
|
|
150
|
+
): DisplayEntry[] {
|
|
151
|
+
var list = Array.isArray(messages) ? messages : [];
|
|
152
|
+
var hasMoreHistory = !!(opts && opts.hasMoreHistory);
|
|
153
|
+
|
|
154
|
+
// One entry per RUN (see IndexingGroup.runKey), addressed by an internal id
|
|
155
|
+
// while the list is being walked; runKey is assigned at the end, once the
|
|
156
|
+
// number of runs per file is known.
|
|
157
|
+
var groups: { [runId: string]: IndexingGroup } = {};
|
|
158
|
+
var order: string[] = [];
|
|
159
|
+
var runOfIndex: (string | undefined)[] = new Array(list.length);
|
|
160
|
+
// A response bubble carries no file metadata of its own; it is tied to its
|
|
161
|
+
// request by the server item id, with the immediately preceding request as
|
|
162
|
+
// the fallback for locally-pushed bubbles that have no id yet.
|
|
163
|
+
var runByItemId: { [itemId: string]: string } = {};
|
|
164
|
+
// Row already opened for a file NAME, so a pass that reports no storage path
|
|
165
|
+
// joins it instead of opening a second row for the same file.
|
|
166
|
+
var keyByName: { [name: string]: string } = {};
|
|
167
|
+
// The run currently being accumulated for each file, every run of it, and the
|
|
168
|
+
// file each run belongs to (a run id is opaque — a file key can contain any
|
|
169
|
+
// character, so it must never be parsed back out of one).
|
|
170
|
+
var openRunOfKey: { [key: string]: string } = {};
|
|
171
|
+
var runsOfKey: { [key: string]: string[] } = {};
|
|
172
|
+
var keyOfRun: { [runId: string]: string } = {};
|
|
173
|
+
var runSeq = 0;
|
|
174
|
+
|
|
175
|
+
for (var i = 0; i < list.length; i++) {
|
|
176
|
+
var msg = list[i];
|
|
177
|
+
if (!msg || !msg.isBackgroundTask) continue;
|
|
178
|
+
|
|
179
|
+
var runId: string | undefined;
|
|
180
|
+
var ref = msg.role === 'user' ? readFileRef(msg) : null;
|
|
181
|
+
if (ref) {
|
|
182
|
+
// Path first — a file can be re-uploaded under a name that already
|
|
183
|
+
// exists elsewhere. But a pass that supplied no path (a compact
|
|
184
|
+
// continuation label recovered from an old history cache) is still the
|
|
185
|
+
// same file as the path-bearing passes above it, so let it join them
|
|
186
|
+
// rather than splitting one file across two collapsed rows.
|
|
187
|
+
var key = ref.path || keyByName[ref.name] || ref.name;
|
|
188
|
+
// A FIRST pass ("A new file has just been uploaded") when this file
|
|
189
|
+
// already has a run open starts a new one: it is a re-index, or a
|
|
190
|
+
// re-upload over the same storage path. Continuations join the run.
|
|
191
|
+
if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
|
|
192
|
+
runId = openRunOfKey[key];
|
|
193
|
+
if (!runId) {
|
|
194
|
+
runId = 'run' + (runSeq++);
|
|
195
|
+
openRunOfKey[key] = runId;
|
|
196
|
+
keyOfRun[runId] = key;
|
|
197
|
+
(runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
|
|
198
|
+
}
|
|
199
|
+
} else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
|
|
200
|
+
runId = runByItemId[msg._serverItemId];
|
|
201
|
+
} else if (msg.role !== 'user') {
|
|
202
|
+
// ADJACENT only. Both paths that create a pass emit its request and
|
|
203
|
+
// response bubbles together, so an id-less response belongs to the
|
|
204
|
+
// message right before it. Falling back to the last file seen ANYWHERE
|
|
205
|
+
// above swallowed stray background bubbles — an indexing prompt whose
|
|
206
|
+
// shape we could not parse, a response whose request is not loaded —
|
|
207
|
+
// into whichever file happened to be indexed most recently, however
|
|
208
|
+
// much unrelated conversation sat in between.
|
|
209
|
+
runId = runOfIndex[i - 1];
|
|
210
|
+
}
|
|
211
|
+
// A background bubble we cannot attribute to a file (an unrecognised label,
|
|
212
|
+
// or a response whose request is not loaded) stays an ordinary message
|
|
213
|
+
// rather than being folded into whichever group happens to be nearest.
|
|
214
|
+
if (!runId) continue;
|
|
215
|
+
|
|
216
|
+
var g = groups[runId];
|
|
217
|
+
if (!g) {
|
|
218
|
+
var fileKey = keyOfRun[runId];
|
|
219
|
+
g = groups[runId] = {
|
|
220
|
+
key: fileKey,
|
|
221
|
+
runKey: runId, // provisional; renumbered newest-first below
|
|
222
|
+
name: ref ? ref.name : fileKey,
|
|
223
|
+
path: ref ? ref.path : undefined,
|
|
224
|
+
mime: ref ? ref.mime : undefined,
|
|
225
|
+
size: ref ? ref.size : undefined,
|
|
226
|
+
isReindex: !!(ref && ref.isReindex),
|
|
227
|
+
members: [],
|
|
228
|
+
passCount: 0,
|
|
229
|
+
status: 'done',
|
|
230
|
+
cancellableIds: [],
|
|
231
|
+
cancelling: false,
|
|
232
|
+
mayHaveOlder: false,
|
|
233
|
+
anchorIndex: i,
|
|
234
|
+
};
|
|
235
|
+
order.push(runId);
|
|
236
|
+
}
|
|
237
|
+
if (ref) {
|
|
238
|
+
// Later passes carry the compact continuation label with no mime/size,
|
|
239
|
+
// so keep the richest values any pass supplied.
|
|
240
|
+
if (ref.name) g.name = ref.name;
|
|
241
|
+
if (ref.path) g.path = ref.path;
|
|
242
|
+
if (ref.mime) g.mime = ref.mime;
|
|
243
|
+
if (typeof ref.size === 'number') g.size = ref.size;
|
|
244
|
+
if (ref.isReindex) g.isReindex = true;
|
|
245
|
+
if (!ref.continued) g.mayHaveOlder = false;
|
|
246
|
+
g.passCount++;
|
|
247
|
+
}
|
|
248
|
+
g.members.push({ msg: msg, index: i });
|
|
249
|
+
g.anchorIndex = i;
|
|
250
|
+
runOfIndex[i] = runId;
|
|
251
|
+
if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
|
|
252
|
+
if (ref && ref.name) keyByName[ref.name] = g.key;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Name each run after its FIRST loaded pass. That id survives everything the
|
|
256
|
+
// list does around it: passes appended to this run, other runs of the same
|
|
257
|
+
// file appearing before OR after it, and an older page prepending. (An
|
|
258
|
+
// ordinal would not — numbering from either end renames existing rows as soon
|
|
259
|
+
// as a run appears at that end, which silently moves the user's expansion to
|
|
260
|
+
// a row they never opened.) The one thing that changes it is the run's own
|
|
261
|
+
// true first pass finally paging in, which happens at most once per run.
|
|
262
|
+
for (var rk in runsOfKey) {
|
|
263
|
+
var runIds = runsOfKey[rk];
|
|
264
|
+
for (var ri = 0; ri < runIds.length; ri++) {
|
|
265
|
+
var grpR = groups[runIds[ri]];
|
|
266
|
+
if (!grpR) continue;
|
|
267
|
+
var first = grpR.members[0];
|
|
268
|
+
var firstId = first && first.msg && (first.msg._serverItemId || first.msg._localId);
|
|
269
|
+
// A run whose passes are all still local (no server id yet) falls back to
|
|
270
|
+
// its position, which is all there is to go on.
|
|
271
|
+
grpR.runKey = rk + '#' + (firstId || 'n' + ri);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Status + completeness, once every member is known.
|
|
276
|
+
for (var oi = 0; oi < order.length; oi++) {
|
|
277
|
+
var grp = groups[order[oi]];
|
|
278
|
+
// A file's passes run strictly one after another, so a pass still marked
|
|
279
|
+
// queued/running with a SETTLED pass after it is stale history, not live
|
|
280
|
+
// work — the server's row for it simply never left "running". Ignoring
|
|
281
|
+
// those matters most for pages reached by scrolling up (or by the viewport
|
|
282
|
+
// fill), which get no poll attached to resolve them: one stale row would
|
|
283
|
+
// otherwise flip a long-finished file back to a spinner with a "Stop"
|
|
284
|
+
// button aimed at a dead item id, permanently.
|
|
285
|
+
var lastSettled = -1;
|
|
286
|
+
for (var si = 0; si < grp.members.length; si++) {
|
|
287
|
+
if (!isPendingMsg(grp.members[si].msg)) lastSettled = si;
|
|
288
|
+
}
|
|
289
|
+
var active = false;
|
|
290
|
+
for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
|
|
291
|
+
if (isPendingMsg(grp.members[mi].msg)) { active = true; break; }
|
|
292
|
+
}
|
|
293
|
+
// What a stop button would act on: the REQUEST bubble of every pass that is
|
|
294
|
+
// still queued or running server-side. The assistant placeholder shares its
|
|
295
|
+
// pass's server id, so ids are de-duplicated. A pass mid-cancel is left out
|
|
296
|
+
// (its request is already on its way) but keeps the row in a cancelling
|
|
297
|
+
// state so the button does not flicker back to "stop".
|
|
298
|
+
for (var xi = 0; xi < grp.members.length; xi++) {
|
|
299
|
+
if (grp.members[xi].msg._cancelling) { grp.cancelling = true; break; }
|
|
300
|
+
}
|
|
301
|
+
var seenIds: { [id: string]: boolean } = {};
|
|
302
|
+
for (var ci = 0; ci < grp.members.length; ci++) {
|
|
303
|
+
var cm = grp.members[ci].msg;
|
|
304
|
+
// Report a failed Stop only while the stop is still something the user
|
|
305
|
+
// can act on. "Could not remove from queue" (the pass had just started)
|
|
306
|
+
// stays on that bubble, so reporting it from ANY member meant a row that
|
|
307
|
+
// went on to finish normally kept describing a one-off transient failure
|
|
308
|
+
// as a permanent property of the file, until a full history refresh
|
|
309
|
+
// rebuilt the bubbles.
|
|
310
|
+
if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
|
|
311
|
+
if (cm.role !== 'user' || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
|
|
312
|
+
if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
|
|
313
|
+
// Same staleness rule: never offer to stop a pass a later one outlived.
|
|
314
|
+
if (ci < lastSettled) continue;
|
|
315
|
+
if (seenIds[cm._serverItemId]) continue;
|
|
316
|
+
seenIds[cm._serverItemId] = true;
|
|
317
|
+
grp.cancellableIds.push(cm._serverItemId);
|
|
318
|
+
}
|
|
319
|
+
if (active) {
|
|
320
|
+
grp.status = 'active';
|
|
321
|
+
} else {
|
|
322
|
+
// The newest loaded outcome is the file's state: an early pass may have
|
|
323
|
+
// errored and a later one succeeded.
|
|
324
|
+
var last = grp.members[grp.members.length - 1].msg;
|
|
325
|
+
grp.status = last.isError ? 'error' : last.isCancelled ? 'cancelled' : 'done';
|
|
326
|
+
}
|
|
327
|
+
// A group whose passes are ALL continuations began before the loaded
|
|
328
|
+
// window; its earlier passes arrive when older history is paged in.
|
|
329
|
+
var sawFirstPass = false;
|
|
330
|
+
for (var pi = 0; pi < grp.members.length; pi++) {
|
|
331
|
+
var pm = grp.members[pi].msg;
|
|
332
|
+
if (pm.role !== 'user') continue;
|
|
333
|
+
var pref = readFileRef(pm);
|
|
334
|
+
if (pref && !pref.continued) { sawFirstPass = true; break; }
|
|
335
|
+
}
|
|
336
|
+
grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
var out: DisplayEntry[] = [];
|
|
340
|
+
for (var j = 0; j < list.length; j++) {
|
|
341
|
+
var r = runOfIndex[j];
|
|
342
|
+
if (r === undefined) {
|
|
343
|
+
out.push({ kind: 'message', msg: list[j], index: j });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Every other member of the run is represented by the row at the anchor.
|
|
347
|
+
if (groups[r].anchorIndex === j) out.push({ kind: 'indexing', group: groups[r], index: j });
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|