@hachej/boring-ui-kit 0.4.46 → 0.4.47
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 +0 -4
- package/dist/chat-react-session.d.ts +1 -0
- package/dist/chat-react-session.js +12 -3
- package/dist/chat-view.d.ts +2 -1
- package/dist/native-chat.d.ts +39 -2
- package/dist/native-chat.js +135 -9
- package/dist/remote-chat.d.ts +6 -0
- package/dist/remote-chat.js +22 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/package.json +2 -6
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +12 -12
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/feedback.json +24 -12
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +12 -12
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +9 -3
- package/r/pi-chat.json +11 -24
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +1211 -1381
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +44 -0
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
- package/dist/task-board.d.ts +0 -96
- package/dist/task-board.js +0 -48
- package/r/feedback-preview.json +0 -20
- package/r/pm-panel.json +0 -122
- package/r/requests-board.json +0 -33
- package/r/task-board.json +0 -41
- package/r/task-mentions.json +0 -25
package/README.md
CHANGED
|
@@ -18,10 +18,6 @@ See [scaffold guide](../../docs/contracts/SCAFFOLD.md) and [audit](../../docs/co
|
|
|
18
18
|
|
|
19
19
|
## Business task board
|
|
20
20
|
|
|
21
|
-
`@hachej/boring-ui-kit/task-board` is a dependency-free host-record model: `TaskRecord`, `TaskState`, `TaskFilter`, `selectTasks`, `taskReference`, `taskFromUrl`, `taskUrl` and `TASK_STATES`. `selectTasks(tasks, { filter, currentUser, limit })` filters then sorts newest-first without mutating input. `mine` requires the host adapter's `requesterId`; it is never an authorization boundary. These business tasks are not native Pi execution tasks. The model also accepts host display metadata (`age`, `requester`, `refiner`, `captureCount`, `folder`), question `askedBy`/`askedAt`, and file `kind: 'markdown' | 'mockup' | 'capture'`. These fields never determine ownership. `deriveTaskStatus` prioritizes the native viewer flag, explicit `waiting_on.type: 'builder'`, then high risk; unknown status yields `none`. `taskStatusLabel(status, locale?, viewerAction?)` supplies EN/FR question versus confirmation wording without inferring workflow authority.
|
|
22
|
-
|
|
23
|
-
Copyable `TaskBoard`, `TaskDetail`, `TaskRequestCard` (also `TaskCard`) and `TaskStatusChip` presentation lives in the [`task-board` registry item](../../registry/README.md#task-board-task-board), using the app's Base UI controls/tokens. The [feature map](../../features/task-board.md) and [fictional example](../../examples/task-board/README.md) drive responsive columns/detail, URL navigation and compact own-request rows.
|
|
24
|
-
|
|
25
21
|
## Exact proposals and commands
|
|
26
22
|
|
|
27
23
|
`actions.propose(selection, edits, summary)` retains an immutable proposal with its exact resource/scope/view/revision, viewer instance/epoch, buffer version, before/after text and ordered edits. It performs no publication and preserves existing human text. Every nonempty `find` must occur once; a missing or ambiguous edit rejects the entire transform.
|
|
@@ -142,6 +142,7 @@ export declare function useChatSession<Content = unknown>(options: ChatSessionOp
|
|
|
142
142
|
loading: boolean;
|
|
143
143
|
developer: boolean;
|
|
144
144
|
natural: boolean;
|
|
145
|
+
sending: boolean;
|
|
145
146
|
send: () => void;
|
|
146
147
|
putBack: (text: string, images: readonly ChatAttachment[]) => void;
|
|
147
148
|
upload: (files: readonly File[], paste?: {
|
|
@@ -49,7 +49,14 @@ export function useChatSession(options) {
|
|
|
49
49
|
return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows
|
|
50
50
|
.map(row => ({ ...row, key: `history:${row.key}`, historical: true }));
|
|
51
51
|
}, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);
|
|
52
|
-
|
|
52
|
+
// What the person just sent is on screen at once, as their own message, until the server's copy arrives (the controller drops it then).
|
|
53
|
+
const echo = state.echo;
|
|
54
|
+
const echoRows = useMemo(() => (echo ?? []).map(item => ({ key: `echo:${item.key}`, type: 'user', local: item.state,
|
|
55
|
+
message: { role: 'user', timestamp: item.sentAt, content: item.draft.attachments.length ? [{ type: 'text', text: item.draft.text }, ...item.draft.attachments.map(attachment => attachment.content)] : item.draft.text } })), [echo]);
|
|
56
|
+
const rows = useMemo(() => [...earlier, ...derived.rows, ...echoRows], [earlier, derived.rows, echoRows]);
|
|
57
|
+
// Tells the controller the echoed messages are on screen (after the commit that drew them), for its timing record.
|
|
58
|
+
useLayoutEffect(() => { if (echo?.length)
|
|
59
|
+
controller.markShown?.(echo.map(item => item.key)); }, [echo, controller]);
|
|
53
60
|
const files = useMemo(() => {
|
|
54
61
|
const found = new Map();
|
|
55
62
|
const open = onOpenFile ?? mentions?.open;
|
|
@@ -121,7 +128,9 @@ export function useChatSession(options) {
|
|
|
121
128
|
if (images.length)
|
|
122
129
|
controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);
|
|
123
130
|
};
|
|
124
|
-
|
|
131
|
+
// Working from the moment of sending: the status shows before the server has taken the message.
|
|
132
|
+
const sending = Boolean(echo?.some(item => item.state === 'sending'));
|
|
133
|
+
const working = Boolean(run) || sending;
|
|
125
134
|
const waitingForAnswer = waitsForAnswer(derived.rows);
|
|
126
135
|
const send = () => {
|
|
127
136
|
if (sendBlocked || !current())
|
|
@@ -356,7 +365,7 @@ export function useChatSession(options) {
|
|
|
356
365
|
const empty = connected && !state.view?.entries.length && !rows.length;
|
|
357
366
|
const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;
|
|
358
367
|
return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
|
|
359
|
-
files, empty, loading, developer, natural, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
|
|
368
|
+
files, empty, loading, developer, natural, sending, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
|
|
360
369
|
pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
|
|
361
370
|
dismissUpload: (id) => setPending(items => items.filter(item => item.id !== id)),
|
|
362
371
|
currentModel: optimistic.model ?? viewModel, currentEffort: optimistic.effort ?? viewEffort,
|
package/dist/chat-view.d.ts
CHANGED
|
@@ -111,7 +111,8 @@ export type Part<Content = unknown> = {
|
|
|
111
111
|
export type Row<Content = unknown> = ({
|
|
112
112
|
readonly key: string;
|
|
113
113
|
readonly type: 'user';
|
|
114
|
-
readonly message: UserMessage; /** `
|
|
114
|
+
readonly message: UserMessage; /** A message just sent that the server has not shown yet (`sending`), or whose send was not confirmed (`failed`). */
|
|
115
|
+
readonly local?: 'sending' | 'failed'; /** `messageStyle="messaging"`: first of a run (see `markLeads`). */
|
|
115
116
|
readonly lead?: boolean;
|
|
116
117
|
} | {
|
|
117
118
|
readonly key: string;
|
package/dist/native-chat.d.ts
CHANGED
|
@@ -98,6 +98,34 @@ export type ChatHistoryState = {
|
|
|
98
98
|
readonly hasMore: boolean;
|
|
99
99
|
readonly error: unknown;
|
|
100
100
|
};
|
|
101
|
+
/**
|
|
102
|
+
* A message the person sent that the server has not shown yet. The chat draws it as their message at once (the echo), and drops it
|
|
103
|
+
* when the server's own copy arrives in the view, so it never shows twice. `failed`: the send was not confirmed (see `send`: `unknown`).
|
|
104
|
+
*/
|
|
105
|
+
export interface EchoMessage {
|
|
106
|
+
readonly key: string;
|
|
107
|
+
readonly draft: ChatDraft;
|
|
108
|
+
/** Wall-clock ms (`Date.now()`) when it was sent. */
|
|
109
|
+
readonly sentAt: number;
|
|
110
|
+
readonly state: 'sending' | 'failed';
|
|
111
|
+
}
|
|
112
|
+
/** Times in ms on `performance.now()` for one sent message, for tests and harnesses; a step not reached yet is absent. */
|
|
113
|
+
export interface SendTiming {
|
|
114
|
+
readonly key: string;
|
|
115
|
+
/** The person pressed Enter (the controller's `send` was called). */
|
|
116
|
+
readonly enter: number;
|
|
117
|
+
/** The message was on screen (the chat's layout effect after it committed the echo). */
|
|
118
|
+
readonly shown?: number;
|
|
119
|
+
/** The first words of the reply reached the view. */
|
|
120
|
+
readonly firstWords?: number;
|
|
121
|
+
/** The reply was complete (the run ended). */
|
|
122
|
+
readonly done?: number;
|
|
123
|
+
}
|
|
124
|
+
export interface ChatTiming {
|
|
125
|
+
/** The connection first became usable (the composer can send). */
|
|
126
|
+
readonly inputReady?: number;
|
|
127
|
+
readonly sends: readonly SendTiming[];
|
|
128
|
+
}
|
|
101
129
|
export interface NativeChatSnapshot {
|
|
102
130
|
readonly identity: ChatIdentity;
|
|
103
131
|
readonly conversationId: Conversation['id'];
|
|
@@ -106,13 +134,20 @@ export interface NativeChatSnapshot {
|
|
|
106
134
|
readonly draft: ChatDraft;
|
|
107
135
|
/** Messages already taken from the composer that wait, in order, for the current send to be confirmed before they are submitted. */
|
|
108
136
|
readonly outbox: readonly ChatDraft[];
|
|
137
|
+
/** Sent messages not yet in the server's view, oldest first: drawn at once as the person's messages. */
|
|
138
|
+
readonly echo: readonly EchoMessage[];
|
|
139
|
+
/** The steps of each send, for tests and harnesses. Also written as `performance.mark('boring-chat:<step>')`. */
|
|
140
|
+
readonly timing: ChatTiming;
|
|
109
141
|
readonly history: ChatHistoryState;
|
|
110
142
|
readonly send: ChatSendState;
|
|
111
143
|
readonly stop: 'idle' | 'requested' | 'confirmed' | 'unconfirmed';
|
|
112
144
|
readonly disposed: boolean;
|
|
113
145
|
}
|
|
114
146
|
/** Structural presentation seam. Native controllers retain their concrete IDs, methods and recovery return values. */
|
|
115
|
-
export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send'> & {
|
|
147
|
+
export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'echo' | 'timing'> & {
|
|
148
|
+
/** Absent from a controller that does not echo sent messages. */
|
|
149
|
+
readonly echo?: NativeChatSnapshot['echo'];
|
|
150
|
+
readonly timing?: NativeChatSnapshot['timing'];
|
|
116
151
|
readonly conversationId: NativeChatSnapshot['conversationId'] | string;
|
|
117
152
|
readonly send: ChatSendState | {
|
|
118
153
|
readonly kind: 'idle' | 'submitting' | 'admitted';
|
|
@@ -122,7 +157,7 @@ export type ChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send'> &
|
|
|
122
157
|
readonly retryable: boolean;
|
|
123
158
|
};
|
|
124
159
|
};
|
|
125
|
-
export type ChatController = Pick<NativeChatController, 'subscribe' | 'setText' | 'setAttachments' | 'clearHistory' | 'loadEarlier' | 'connect' | 'stop' | 'dispose'> & {
|
|
160
|
+
export type ChatController = Partial<Pick<NativeChatController, 'markShown'>> & Pick<NativeChatController, 'subscribe' | 'setText' | 'setAttachments' | 'clearHistory' | 'loadEarlier' | 'connect' | 'stop' | 'dispose'> & {
|
|
126
161
|
readonly getSnapshot: () => ChatSnapshot;
|
|
127
162
|
readonly send: (whenBusy?: ChatAttempt['whenBusy'], input?: ChatSendInput) => Promise<unknown>;
|
|
128
163
|
readonly reconcile: () => Promise<unknown>;
|
|
@@ -147,6 +182,8 @@ export interface NativeChatOptions {
|
|
|
147
182
|
export declare function createNativeChatController(options: NativeChatOptions): {
|
|
148
183
|
conversation: ChatConversation;
|
|
149
184
|
getSnapshot: () => NativeChatSnapshot;
|
|
185
|
+
/** The chat reports which echoed messages are now on screen (called after the commit that drew them); it stamps `timing`. */
|
|
186
|
+
markShown: (keys: readonly string[]) => void;
|
|
150
187
|
/** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
|
|
151
188
|
submissionStatus: (submission: Submission) => Promise<SubmissionRecord>;
|
|
152
189
|
subscribe: (listener: () => void) => (() => void);
|
package/dist/native-chat.js
CHANGED
|
@@ -20,7 +20,7 @@ export function createNativeChatController(options) {
|
|
|
20
20
|
if (Object.values(identity).some(value => typeof value !== 'string' || !value))
|
|
21
21
|
throw new TypeError('Explicit runtime, scope and principal identity are required');
|
|
22
22
|
let snapshot = { identity, conversationId: conversation.id, connection: { kind: 'idle' },
|
|
23
|
-
draft: draft('', [], 0), outbox: Object.freeze([]), history: emptyHistory(), send: { kind: 'idle' }, stop: 'idle', disposed: false };
|
|
23
|
+
draft: draft('', [], 0), outbox: Object.freeze([]), echo: Object.freeze([]), timing: Object.freeze({ sends: Object.freeze([]) }), history: emptyHistory(), send: { kind: 'idle' }, stop: 'idle', disposed: false };
|
|
24
24
|
const listeners = new Set();
|
|
25
25
|
let watch, opening, disposing;
|
|
26
26
|
const publish = (change) => {
|
|
@@ -79,11 +79,101 @@ export function createNativeChatController(options) {
|
|
|
79
79
|
item.textObserved = true;
|
|
80
80
|
}
|
|
81
81
|
};
|
|
82
|
+
let echoSeq = 0;
|
|
83
|
+
const echoes = [];
|
|
84
|
+
/** A sent message still not in the view after this long is dropped from the echo (the server may have changed its words); the transcript is the truth. */
|
|
85
|
+
const ECHO_LIFETIME_MS = 120_000;
|
|
86
|
+
const now = () => (globalThis.performance?.now?.() ?? Date.now());
|
|
87
|
+
const textOfContent = (content) => typeof content === 'string' ? content
|
|
88
|
+
: Array.isArray(content) ? content.flatMap(part => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string' ? [part.text] : []).join('\n') : '';
|
|
89
|
+
/** The texts of the people's messages the view holds: placed ones and those waiting in the inbox. */
|
|
90
|
+
const userTexts = (view) => {
|
|
91
|
+
if (!view)
|
|
92
|
+
return [];
|
|
93
|
+
const texts = view.entries.flatMap(entry => (entry.model ?? []).flatMap(message => message.role === 'user' ? [textOfContent(message.content)] : []));
|
|
94
|
+
const items = view.docs['pi.inbox'] && typeof view.docs['pi.inbox'] === 'object' && !Array.isArray(view.docs['pi.inbox']) ? view.docs['pi.inbox']['items'] : undefined;
|
|
95
|
+
if (Array.isArray(items))
|
|
96
|
+
for (const item of items)
|
|
97
|
+
if (item && typeof item === 'object' && !Array.isArray(item))
|
|
98
|
+
texts.push(textOfContent(item['content']));
|
|
99
|
+
return texts;
|
|
100
|
+
};
|
|
101
|
+
const countOf = (view, match) => userTexts(view).filter(text => match === '' || text.includes(match)).length;
|
|
102
|
+
const echoView = () => Object.freeze(echoes.map(item => Object.freeze({ key: item.key, draft: item.draft, sentAt: item.sentAt, state: item.failed ? 'failed' : 'sending' })));
|
|
103
|
+
/** Applies a change to the echo list and returns the snapshot field for it. */
|
|
104
|
+
const publishEcho = (change = {}) => publish({ ...change, echo: echoView() });
|
|
105
|
+
const echoOf = (draft) => echoes.find(item => item.draft === draft);
|
|
106
|
+
const dropEcho = (drafts) => {
|
|
107
|
+
let changed = false;
|
|
108
|
+
for (const item of drafts) {
|
|
109
|
+
const found = echoOf(item);
|
|
110
|
+
if (found) {
|
|
111
|
+
echoes.splice(echoes.indexOf(found), 1);
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return changed;
|
|
116
|
+
};
|
|
117
|
+
// Timing: one record per send, kept for the last 20, readable from the snapshot and as performance marks.
|
|
118
|
+
const timings = [];
|
|
119
|
+
const mark = (step) => {
|
|
120
|
+
const at = now();
|
|
121
|
+
try {
|
|
122
|
+
globalThis.performance?.mark?.(`boring-chat:${step}`);
|
|
123
|
+
}
|
|
124
|
+
catch { /* marks are optional */ }
|
|
125
|
+
return at;
|
|
126
|
+
};
|
|
127
|
+
const stamp = (key, step) => {
|
|
128
|
+
const index = timings.findIndex(item => item.key === key);
|
|
129
|
+
if (index < 0 || timings[index][step] !== undefined)
|
|
130
|
+
return;
|
|
131
|
+
timings[index] = Object.freeze({ ...timings[index], [step]: mark(`${step}:${key}`) });
|
|
132
|
+
};
|
|
133
|
+
const publishTiming = (change = {}) => publish({ ...change, timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) });
|
|
134
|
+
/** Reads the view for the steps of the sends: the first words of a reply and its end. */
|
|
135
|
+
const observeTiming = (view) => {
|
|
136
|
+
const open = timings.filter(item => item.done === undefined && item.enter !== undefined);
|
|
137
|
+
if (!open.length)
|
|
138
|
+
return;
|
|
139
|
+
const live = view.docs['pi.live'], run = live && typeof live === 'object' && !Array.isArray(live) ? live['run'] : undefined;
|
|
140
|
+
const running = Boolean(run) && typeof run === 'object';
|
|
141
|
+
const generation = live && typeof live === 'object' && !Array.isArray(live) ? live['generation'] : undefined;
|
|
142
|
+
const message = generation && typeof generation === 'object' && !Array.isArray(generation) ? generation['message'] : undefined;
|
|
143
|
+
const streamed = message && typeof message === 'object' && !Array.isArray(message) && hasText([{ role: 'assistant', content: message['content'] }]);
|
|
144
|
+
const key = open[0].key;
|
|
145
|
+
const placed = view.entries.slice(firstEntries.get(key) ?? view.entries.length).some(entry => hasText(entry.model));
|
|
146
|
+
if (streamed || placed)
|
|
147
|
+
stamp(key, 'firstWords');
|
|
148
|
+
if (!running && timings.find(item => item.key === key)?.firstWords !== undefined)
|
|
149
|
+
stamp(key, 'done');
|
|
150
|
+
};
|
|
151
|
+
/** How many entries the view held when a message was sent: text after them is the reply. */
|
|
152
|
+
const firstEntries = new Map();
|
|
153
|
+
/** Drops the echoes whose message the view now holds (or that waited too long). */
|
|
154
|
+
const settleEchoes = (view) => {
|
|
155
|
+
if (!echoes.length)
|
|
156
|
+
return false;
|
|
157
|
+
const seen = new Map();
|
|
158
|
+
const remaining = echoes.filter(item => {
|
|
159
|
+
if (now() - item.at > ECHO_LIFETIME_MS && item.admitted)
|
|
160
|
+
return false;
|
|
161
|
+
const count = seen.get(item.match) ?? countOf(view, item.match);
|
|
162
|
+
seen.set(item.match, count);
|
|
163
|
+
return count < item.base + 1;
|
|
164
|
+
});
|
|
165
|
+
if (remaining.length === echoes.length)
|
|
166
|
+
return false;
|
|
167
|
+
echoes.splice(0, echoes.length, ...remaining);
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
82
170
|
const acceptView = (view) => {
|
|
83
171
|
observeText(view);
|
|
84
172
|
const previous = snapshot.view?.entries;
|
|
85
173
|
const appendOnly = !previous || previous.every((entry, index) => view.entries[index]?.id === entry.id);
|
|
86
|
-
|
|
174
|
+
const changed = settleEchoes(view);
|
|
175
|
+
observeTiming(view);
|
|
176
|
+
publish({ view, ...(!appendOnly ? { history: invalidateHistory() } : {}), ...(changed ? { echo: echoView() } : {}), timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) });
|
|
87
177
|
};
|
|
88
178
|
const stillPending = (attempt) => (snapshot.send.kind === 'unknown' || snapshot.send.kind === 'submitting') && snapshot.send.attempt.requestId === attempt.requestId;
|
|
89
179
|
const active = () => { if (snapshot.disposed)
|
|
@@ -93,7 +183,12 @@ export function createNativeChatController(options) {
|
|
|
93
183
|
const markAdmitted = (attempt, submissionId, record) => {
|
|
94
184
|
if (snapshot.disposed || !stillPending(attempt))
|
|
95
185
|
return false;
|
|
96
|
-
|
|
186
|
+
const found = echoOf(attempt.draft);
|
|
187
|
+
if (found) {
|
|
188
|
+
found.failed = false;
|
|
189
|
+
found.admitted = true;
|
|
190
|
+
}
|
|
191
|
+
publish({ send: { kind: 'admitted', attempt, submissionId, ...(record ? { record } : {}) }, ...(found ? { echo: echoView() } : {}) });
|
|
97
192
|
return true;
|
|
98
193
|
};
|
|
99
194
|
/** Messages taken from the composer that wait for the current send (`outbox` in the snapshot). */
|
|
@@ -111,7 +206,8 @@ export function createNativeChatController(options) {
|
|
|
111
206
|
item.resolve(undefined);
|
|
112
207
|
if (snapshot.disposed)
|
|
113
208
|
return;
|
|
114
|
-
|
|
209
|
+
const gone = dropEcho([...drafts, ...left.map(item => item.draft)]);
|
|
210
|
+
putBack([...drafts, ...left.map(item => item.draft)], { ...change, ...(gone ? { echo: echoView() } : {}), ...(left.length || snapshot.outbox.length ? { outbox: Object.freeze([]) } : {}) });
|
|
115
211
|
};
|
|
116
212
|
function putBack(selected, change = {}) {
|
|
117
213
|
if (snapshot.disposed)
|
|
@@ -177,7 +273,10 @@ export function createNativeChatController(options) {
|
|
|
177
273
|
})();
|
|
178
274
|
}
|
|
179
275
|
async function submitAttempt(attempt) {
|
|
180
|
-
|
|
276
|
+
const retried = echoOf(attempt.draft);
|
|
277
|
+
if (retried)
|
|
278
|
+
retried.failed = false;
|
|
279
|
+
publish({ send: { kind: 'submitting', attempt }, ...(retried ? { echo: echoView() } : {}), ...(!stopping && snapshot.stop !== 'idle' ? { stop: 'idle' } : {}) });
|
|
181
280
|
if (snapshot.disposed)
|
|
182
281
|
return;
|
|
183
282
|
try {
|
|
@@ -196,8 +295,12 @@ export function createNativeChatController(options) {
|
|
|
196
295
|
// Refused: the message goes back into the composer. Unknown: it stays held by the attempt for `reconcile`/`retrySameRequest`.
|
|
197
296
|
if (denied)
|
|
198
297
|
restore([attempt.draft], { send: { kind: 'blocked', reason: error instanceof Error ? error.message : 'Submission was not admitted' } });
|
|
199
|
-
else
|
|
200
|
-
|
|
298
|
+
else {
|
|
299
|
+
const found = echoOf(attempt.draft);
|
|
300
|
+
if (found)
|
|
301
|
+
found.failed = true;
|
|
302
|
+
publish({ send: { kind: 'unknown', attempt }, ...(found ? { echo: echoView() } : {}) });
|
|
303
|
+
}
|
|
201
304
|
return undefined;
|
|
202
305
|
}
|
|
203
306
|
}
|
|
@@ -228,6 +331,16 @@ export function createNativeChatController(options) {
|
|
|
228
331
|
const controller = {
|
|
229
332
|
conversation,
|
|
230
333
|
getSnapshot: () => snapshot,
|
|
334
|
+
/** The chat reports which echoed messages are now on screen (called after the commit that drew them); it stamps `timing`. */
|
|
335
|
+
markShown: (keys) => {
|
|
336
|
+
if (snapshot.disposed)
|
|
337
|
+
return;
|
|
338
|
+
const before = timings.map(item => item.shown);
|
|
339
|
+
for (const key of keys)
|
|
340
|
+
stamp(key, 'shown');
|
|
341
|
+
if (timings.some((item, index) => item.shown !== before[index]))
|
|
342
|
+
publishTiming();
|
|
343
|
+
},
|
|
231
344
|
/** Reads the exact native submission with this controller's Context; observing never aborts or owns its execution. */
|
|
232
345
|
submissionStatus: async (submission) => {
|
|
233
346
|
active();
|
|
@@ -331,7 +444,8 @@ export function createNativeChatController(options) {
|
|
|
331
444
|
throw new Error('Chat source returned another conversation');
|
|
332
445
|
}
|
|
333
446
|
watch = acquired;
|
|
334
|
-
|
|
447
|
+
const ready = snapshot.timing.inputReady === undefined;
|
|
448
|
+
publish({ connection: live(), view: acquired.value, ...(ready ? { timing: Object.freeze({ inputReady: mark('input-ready'), sends: Object.freeze([...timings]) }) } : {}) });
|
|
335
449
|
acquired.start(async (view) => {
|
|
336
450
|
if (snapshot.disposed || watch !== acquired)
|
|
337
451
|
return;
|
|
@@ -376,7 +490,19 @@ export function createNativeChatController(options) {
|
|
|
376
490
|
}
|
|
377
491
|
if (input?.restore)
|
|
378
492
|
restoreOf.set(selected, draft(input.restore.text, input.restore.attachments ?? [], 0));
|
|
379
|
-
|
|
493
|
+
// The message is the person's from this moment: it is drawn at once (the echo) and the composer is emptied, before any
|
|
494
|
+
// validation or network call. The server's copy replaces it when it arrives; a refusal puts the words back in the composer.
|
|
495
|
+
// (A steer takes a queued message back and sends it again, so it is not echoed twice.)
|
|
496
|
+
if (whenBusy !== 'steer') {
|
|
497
|
+
const enter = mark('enter');
|
|
498
|
+
const match = selected.text.trim(), key = `m${++echoSeq}`;
|
|
499
|
+
echoes.push({ key, draft: selected, match, base: countOf(snapshot.view, match) + echoes.filter(item => item.match === match).length, at: enter, sentAt: Date.now(), failed: false, admitted: false });
|
|
500
|
+
firstEntries.set(key, snapshot.view?.entries.length ?? 0);
|
|
501
|
+
timings.push(Object.freeze({ key, enter }));
|
|
502
|
+
if (timings.length > 20)
|
|
503
|
+
firstEntries.delete(timings.shift().key);
|
|
504
|
+
}
|
|
505
|
+
const cleared = { ...(input ? {} : { draft: draft('', [], current.version + 1) }), echo: echoView(), timing: Object.freeze({ ...(snapshot.timing.inputReady !== undefined ? { inputReady: snapshot.timing.inputReady } : {}), sends: Object.freeze([...timings]) }) };
|
|
380
506
|
if (sending) {
|
|
381
507
|
return new Promise(resolve => {
|
|
382
508
|
waiting.push({ draft: selected, whenBusy, resolve });
|
package/dist/remote-chat.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export interface RemoteChatOptions {
|
|
|
45
45
|
readonly open?: (url: URL) => WebSocket;
|
|
46
46
|
readonly pingMs?: number;
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* How long the first view of a connection may take before the chat gives up and reports an error (default 18000). Without it a
|
|
50
|
+
* host that accepts the watch request but never sends a view leaves the page on « Connecting… » for good. Later views are
|
|
51
|
+
* bounded by the heartbeat.
|
|
52
|
+
*/
|
|
53
|
+
readonly firstViewTimeoutMs?: number;
|
|
48
54
|
/** Host events the socket carries beside the chat (`broadcast` on the server, for example presence or the conversation list). */
|
|
49
55
|
readonly onEvent?: (event: unknown) => void;
|
|
50
56
|
}
|
package/dist/remote-chat.js
CHANGED
|
@@ -99,6 +99,7 @@ export async function createRemoteChat(options) {
|
|
|
99
99
|
return;
|
|
100
100
|
closed = true;
|
|
101
101
|
clearInterval(timer);
|
|
102
|
+
clearTimeout(firstView);
|
|
102
103
|
if (current === socket)
|
|
103
104
|
current = undefined;
|
|
104
105
|
notify();
|
|
@@ -122,6 +123,14 @@ export async function createRemoteChat(options) {
|
|
|
122
123
|
socket.close(1000);
|
|
123
124
|
}
|
|
124
125
|
catch { /* closing */ } finish(); };
|
|
126
|
+
// A socket that opens but never sends its first view is closed (then HTTP is tried, or the error is reported).
|
|
127
|
+
const firstView = setTimeout(() => { if (!opened) {
|
|
128
|
+
try {
|
|
129
|
+
socket.close();
|
|
130
|
+
}
|
|
131
|
+
catch { /* closing */ }
|
|
132
|
+
finish();
|
|
133
|
+
} }, options.firstViewTimeoutMs ?? 18_000);
|
|
125
134
|
socket.onclose = finish;
|
|
126
135
|
socket.onerror = () => { try {
|
|
127
136
|
socket.close();
|
|
@@ -169,6 +178,19 @@ export async function createRemoteChat(options) {
|
|
|
169
178
|
}
|
|
170
179
|
async function httpSource() {
|
|
171
180
|
const stopped = new AbortController();
|
|
181
|
+
// The whole wait for the first view (the response, then its first line) is bounded: a host that never answers is an error with retry.
|
|
182
|
+
const limit = options.firstViewTimeoutMs ?? 18_000;
|
|
183
|
+
let giveUp;
|
|
184
|
+
const late = new Promise((_, reject) => { giveUp = setTimeout(() => { stopped.abort(); reject(new WatchRefused('Remote chat watch timed out before its first view', 408)); }, limit); });
|
|
185
|
+
late.catch(() => { });
|
|
186
|
+
try {
|
|
187
|
+
return await Promise.race([openHttp(stopped), late]);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
clearTimeout(giveUp);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function openHttp(stopped) {
|
|
172
194
|
const response = await send(new Request(url('watch'), { signal: stopped.signal }));
|
|
173
195
|
if (!response.ok || !response.body)
|
|
174
196
|
throw new WatchRefused(`Remote chat watch failed: ${await reason(response)}`, response.status);
|
|
@@ -3,7 +3,7 @@ export { ChannelRequestError } from './error.js';
|
|
|
3
3
|
import type { Card, ChannelMessage, ChannelSource, Member, SharedChatState } from './source.js';
|
|
4
4
|
export type * from './source.js';
|
|
5
5
|
export { channelAvatarStates, cardActions } from './source.js';
|
|
6
|
-
export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared'> & {
|
|
6
|
+
export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared' | 'echo' | 'timing'> & {
|
|
7
7
|
readonly conversationId: string;
|
|
8
8
|
readonly shared: SharedChatState;
|
|
9
9
|
readonly send: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.47",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -147,14 +147,10 @@
|
|
|
147
147
|
"./mark-store": {
|
|
148
148
|
"types": "./dist/mark-store.d.ts",
|
|
149
149
|
"import": "./dist/mark-store.js"
|
|
150
|
-
},
|
|
151
|
-
"./task-board": {
|
|
152
|
-
"types": "./dist/task-board.d.ts",
|
|
153
|
-
"import": "./dist/task-board.js"
|
|
154
150
|
}
|
|
155
151
|
},
|
|
156
152
|
"peerDependencies": {
|
|
157
|
-
"@hachej/boring-files": "^0.4.
|
|
153
|
+
"@hachej/boring-files": "^0.4.47",
|
|
158
154
|
"@earendil-works/pi-durable": "~1.4.0",
|
|
159
155
|
"react": "^19.0.0",
|
|
160
156
|
"react-dom": "^19.0.0",
|
package/r/agent-target.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
"title": "Point and dictate on app data",
|
|
5
5
|
"description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.47",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.47",
|
|
9
9
|
"@earendil-works/pi-durable@~1.4.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0"
|
|
12
12
|
],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|