@specific.dev/spectest 0.105.0 → 0.107.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 +42 -0
- package/dist/annotate.d.ts +36 -0
- package/dist/annotate.js +32 -1
- package/dist/recorder.d.ts +18 -0
- package/package.json +1 -1
- package/src/annotate.test.ts +127 -2
- package/src/annotate.ts +68 -1
- package/src/recorder.ts +20 -1
package/README.md
CHANGED
|
@@ -86,6 +86,48 @@ return annotate(turns, "chat", {
|
|
|
86
86
|
The dashboard draws the chips on the bubble's lower corner, opposite the
|
|
87
87
|
sender, and slides a new one in after the bubbles that arrived with it.
|
|
88
88
|
|
|
89
|
+
## Read receipts and typing (0.106.0)
|
|
90
|
+
|
|
91
|
+
A `chat` message can carry its delivery `status` (`delivered` or `read`)
|
|
92
|
+
and an optional `statusAt` — free text such as `"23:14"` — drawn as the
|
|
93
|
+
caption a messaging app shows under the bubble, on the bubble's own side.
|
|
94
|
+
The annotation itself takes `typing: "self" | "other"`, drawn as the
|
|
95
|
+
three-dot bubble at the end of the thread on that side. Both are the fake's
|
|
96
|
+
to set, like `new`; typing is transient, so a step either shows it or not.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
return annotate(turns, "chat", {
|
|
100
|
+
typing: state.agentTyping ? "other" : undefined,
|
|
101
|
+
messages: turns.map((t) => ({
|
|
102
|
+
side: t.fromCustomer ? "self" : "other",
|
|
103
|
+
text: t.body,
|
|
104
|
+
status: t.readAt ? "read" : "delivered",
|
|
105
|
+
statusAt: t.readAt,
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Inline replies (0.107.0)
|
|
111
|
+
|
|
112
|
+
A `chat` message can carry an `id` — any non-empty string the fake chooses,
|
|
113
|
+
the provider's message handle usually; it is never drawn — and a reply names
|
|
114
|
+
an earlier message's `id` as `replyTo`. The dashboard draws the reply the
|
|
115
|
+
way a messaging app threads one: an excerpt of the earlier message in a
|
|
116
|
+
muted mini-bubble directly above the reply, on the reply's side, with a
|
|
117
|
+
short connector between them. A `replyTo` that names no earlier message of
|
|
118
|
+
the same call draws nothing extra; the bubble is an ordinary message.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
return annotate(turns, "chat", {
|
|
122
|
+
messages: turns.map((t) => ({
|
|
123
|
+
id: t.id,
|
|
124
|
+
side: t.fromCustomer ? "self" : "other",
|
|
125
|
+
text: t.body,
|
|
126
|
+
replyTo: t.inReplyTo,
|
|
127
|
+
})),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
89
131
|
## HTTP assertions
|
|
90
132
|
|
|
91
133
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/annotate.d.ts
CHANGED
|
@@ -95,6 +95,14 @@ export interface ChatAnnotation {
|
|
|
95
95
|
* reads fine without it.
|
|
96
96
|
*/
|
|
97
97
|
title?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Who is writing right now — `self` the end user, `other` whoever they
|
|
100
|
+
* are talking to — drawn as the three-dot bubble at the end of the
|
|
101
|
+
* thread on that side. It is the thread's present tense: a call either
|
|
102
|
+
* shows it or not, so there is no `new` to mark. Any other value is
|
|
103
|
+
* nobody typing.
|
|
104
|
+
*/
|
|
105
|
+
typing?: "self" | "other";
|
|
98
106
|
}
|
|
99
107
|
/** One turn of a {@link ChatAnnotation}. */
|
|
100
108
|
export interface ChatMessageAnnotation {
|
|
@@ -119,6 +127,34 @@ export interface ChatMessageAnnotation {
|
|
|
119
127
|
* corner. A reaction that was removed simply stops being listed.
|
|
120
128
|
*/
|
|
121
129
|
reactions?: readonly ChatReactionAnnotation[];
|
|
130
|
+
/**
|
|
131
|
+
* Delivery status, as the provider reported it: drawn as the small
|
|
132
|
+
* caption under the bubble ("Delivered", "Read"), on the bubble's own
|
|
133
|
+
* side. Any other value is left off — a caption, unlike a turn, can be
|
|
134
|
+
* dropped without the transcript lying.
|
|
135
|
+
*/
|
|
136
|
+
status?: "delivered" | "read";
|
|
137
|
+
/**
|
|
138
|
+
* When, as the app would show it ("23:14"): free text drawn after the
|
|
139
|
+
* status ("Read 23:14"). Rides with `status`; alone it has nothing to
|
|
140
|
+
* qualify and is dropped.
|
|
141
|
+
*/
|
|
142
|
+
statusAt?: string;
|
|
143
|
+
/**
|
|
144
|
+
* The author's handle for this message — the provider's message id,
|
|
145
|
+
* usually — so a later message can reply to it by name. Any non-empty
|
|
146
|
+
* string; the dashboard never draws it.
|
|
147
|
+
*/
|
|
148
|
+
id?: string;
|
|
149
|
+
/**
|
|
150
|
+
* The `id` of an earlier message in this transcript that this one
|
|
151
|
+
* replies to, drawn the way a messaging app threads a reply: an excerpt
|
|
152
|
+
* of that message quoted above this bubble, on this bubble's side. One
|
|
153
|
+
* that names no earlier message of the same call — an unknown id, a
|
|
154
|
+
* later message, the message itself — draws nothing extra, and the
|
|
155
|
+
* bubble reads as an ordinary message.
|
|
156
|
+
*/
|
|
157
|
+
replyTo?: string;
|
|
122
158
|
}
|
|
123
159
|
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
124
160
|
export interface ChatReactionAnnotation {
|
package/dist/annotate.js
CHANGED
|
@@ -138,6 +138,7 @@ function lowerChat(options, value) {
|
|
|
138
138
|
const source = deepUnwrap(options ?? value);
|
|
139
139
|
let list;
|
|
140
140
|
let title;
|
|
141
|
+
let typing;
|
|
141
142
|
if (Array.isArray(source)) {
|
|
142
143
|
list = source;
|
|
143
144
|
}
|
|
@@ -145,6 +146,7 @@ function lowerChat(options, value) {
|
|
|
145
146
|
const bag = fields(source);
|
|
146
147
|
list = bag.messages;
|
|
147
148
|
title = text(bag.title) || undefined;
|
|
149
|
+
typing = typingOf(bag.typing);
|
|
148
150
|
}
|
|
149
151
|
// An empty conversation is a real state (nothing said yet) and renders as
|
|
150
152
|
// one; a value with no `messages` at all is the author's mistake.
|
|
@@ -157,7 +159,9 @@ function lowerChat(options, value) {
|
|
|
157
159
|
return {
|
|
158
160
|
kind: "chat",
|
|
159
161
|
title,
|
|
160
|
-
blocks: [
|
|
162
|
+
blocks: [
|
|
163
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
164
|
+
],
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
function chatMessageOf(src) {
|
|
@@ -176,8 +180,35 @@ function chatMessageOf(src) {
|
|
|
176
180
|
const reactions = reactionsOf(src.reactions);
|
|
177
181
|
if (reactions.length > 0)
|
|
178
182
|
msg.reactions = reactions;
|
|
183
|
+
// A status caption, unlike a turn, can be left off without the
|
|
184
|
+
// transcript lying, so one that isn't `delivered` or `read` is dropped
|
|
185
|
+
// rather than guessed at. Its time rides with it: free text, truncated
|
|
186
|
+
// like a body, and meaningless without a status to qualify.
|
|
187
|
+
const status = src.status;
|
|
188
|
+
if (status === "delivered" || status === "read") {
|
|
189
|
+
msg.status = status;
|
|
190
|
+
if (typeof src.statusAt === "string" && src.statusAt !== "") {
|
|
191
|
+
msg.statusAt = truncateUtf8(src.statusAt).value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// A reply threads to an earlier message by the handle its author gave
|
|
195
|
+
// it, so both ride through as given — a non-empty string, cut like a
|
|
196
|
+
// body — and anything else is dropped. Whether `replyTo` names an
|
|
197
|
+
// earlier message of the transcript is the dashboard's to decide: it
|
|
198
|
+
// draws the quote when it can and an ordinary bubble when it cannot.
|
|
199
|
+
if (typeof src.id === "string" && src.id !== "")
|
|
200
|
+
msg.id = truncateUtf8(src.id).value;
|
|
201
|
+
if (typeof src.replyTo === "string" && src.replyTo !== "") {
|
|
202
|
+
msg.replyTo = truncateUtf8(src.replyTo).value;
|
|
203
|
+
}
|
|
179
204
|
return msg;
|
|
180
205
|
}
|
|
206
|
+
/** The block's `typing`: `self` or `other`, else nobody. The thread's
|
|
207
|
+
* present tense is a decoration, not a turn, so a value that isn't one of
|
|
208
|
+
* the two draws nothing rather than guessing at who. */
|
|
209
|
+
function typingOf(v) {
|
|
210
|
+
return v === "self" || v === "other" ? v : undefined;
|
|
211
|
+
}
|
|
181
212
|
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
182
213
|
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
183
214
|
* the other party's — and only one with nothing to draw (no string
|
package/dist/recorder.d.ts
CHANGED
|
@@ -631,6 +631,7 @@ export type StepBlock = {
|
|
|
631
631
|
type: "chat";
|
|
632
632
|
messages: ChatBlockMessage[];
|
|
633
633
|
label?: string;
|
|
634
|
+
typing?: "self" | "other";
|
|
634
635
|
} | {
|
|
635
636
|
type: "details";
|
|
636
637
|
summary: string;
|
|
@@ -651,6 +652,23 @@ export interface ChatBlockMessage {
|
|
|
651
652
|
* corner, on the side opposite the sender. Absent and empty read the
|
|
652
653
|
* same: nothing to draw. */
|
|
653
654
|
reactions?: ChatBlockReaction[];
|
|
655
|
+
/** Delivery status — the caption a messaging app shows under a bubble
|
|
656
|
+
* ("Delivered", "Read"), on the bubble's own side. Absent draws
|
|
657
|
+
* nothing. */
|
|
658
|
+
status?: "delivered" | "read";
|
|
659
|
+
/** When the status happened, as the app would show it ("23:14"): free
|
|
660
|
+
* text drawn after the status caption ("Read 23:14"). Rides with
|
|
661
|
+
* `status`; alone it has nothing to qualify. */
|
|
662
|
+
statusAt?: string;
|
|
663
|
+
/** The author's handle for this message — the provider's message id,
|
|
664
|
+
* usually — which a later message's `replyTo` names. Never drawn.
|
|
665
|
+
* Absent when the author gave none. */
|
|
666
|
+
id?: string;
|
|
667
|
+
/** The `id` of an earlier message in the same block this one replies
|
|
668
|
+
* to, drawn as a quoted excerpt of that message above the bubble, the
|
|
669
|
+
* way a messaging app threads a reply. One that names no earlier
|
|
670
|
+
* message draws nothing extra. */
|
|
671
|
+
replyTo?: string;
|
|
654
672
|
}
|
|
655
673
|
/** One reaction on a {@link ChatBlockMessage}. */
|
|
656
674
|
export interface ChatBlockReaction {
|
package/package.json
CHANGED
package/src/annotate.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { annotate, readAnnotation } from "./annotate.js";
|
|
3
|
+
import { annotate, readAnnotation, type ChatMessageAnnotation } from "./annotate.js";
|
|
4
|
+
import { truncateUtf8 } from "./recorder.js";
|
|
4
5
|
|
|
5
6
|
/** Open a chat annotation: the messages its one block lowered to, and the
|
|
6
7
|
* value the test would receive. */
|
|
@@ -9,7 +10,7 @@ function chat(boxed: unknown) {
|
|
|
9
10
|
if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
|
|
10
11
|
const block = opened.annotation.blocks[0];
|
|
11
12
|
if (!block || block.type !== "chat") throw new Error("not a chat block");
|
|
12
|
-
return { messages: block.messages, value: opened.value };
|
|
13
|
+
return { messages: block.messages, typing: block.typing, block, value: opened.value };
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
describe("annotate chat", () => {
|
|
@@ -77,3 +78,127 @@ describe("annotate chat", () => {
|
|
|
77
78
|
expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
|
|
78
79
|
});
|
|
79
80
|
});
|
|
81
|
+
|
|
82
|
+
describe("annotate chat status and typing", () => {
|
|
83
|
+
test("captions a message with its delivery status and leaves the rest alone", () => {
|
|
84
|
+
const { messages } = chat(
|
|
85
|
+
annotate(
|
|
86
|
+
[
|
|
87
|
+
{ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" },
|
|
88
|
+
{ side: "self", text: "hello?", status: "delivered" },
|
|
89
|
+
{ side: "other", text: "It ships today.", new: true },
|
|
90
|
+
],
|
|
91
|
+
"chat",
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
expect(messages[0]).toEqual({ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" });
|
|
95
|
+
// A status without a time carries no `statusAt`; a message without a
|
|
96
|
+
// status carries neither key.
|
|
97
|
+
expect(messages[1]).toEqual({ side: "self", text: "hello?", status: "delivered" });
|
|
98
|
+
expect(messages[2]).toEqual({ side: "other", text: "It ships today.", new: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// A caption, unlike a turn, can be left off without the transcript
|
|
102
|
+
// lying: a status that isn't one of the two is dropped, and so is a time
|
|
103
|
+
// that isn't text, is empty, or has no status to qualify.
|
|
104
|
+
test("drops a status it does not know and a time it cannot draw", () => {
|
|
105
|
+
const { messages } = chat(
|
|
106
|
+
annotate(
|
|
107
|
+
[
|
|
108
|
+
{ side: "self", text: "a", status: "seen", statusAt: "23:14" },
|
|
109
|
+
{ side: "self", text: "b", status: "read", statusAt: 2314 },
|
|
110
|
+
{ side: "self", text: "c", statusAt: "23:14" },
|
|
111
|
+
{ side: "self", text: "d", status: "read", statusAt: "" },
|
|
112
|
+
],
|
|
113
|
+
"chat",
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
expect(messages[0]).toEqual({ side: "self", text: "a" });
|
|
117
|
+
expect(messages[1]).toEqual({ side: "self", text: "b", status: "read" });
|
|
118
|
+
expect(messages[2]).toEqual({ side: "self", text: "c" });
|
|
119
|
+
expect(messages[3]).toEqual({ side: "self", text: "d", status: "read" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a typing indicator rides on the block, and only for a side it knows", () => {
|
|
123
|
+
const messages: ChatMessageAnnotation[] = [{ side: "other", text: "Let me check." }];
|
|
124
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "other" })).typing).toBe("other");
|
|
125
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "self" })).typing).toBe("self");
|
|
126
|
+
// Nobody typing is no key at all: absent, misspelled, or a bare flag.
|
|
127
|
+
// (Read off the value, which skips the options' typing.)
|
|
128
|
+
expect(chat(annotate({ id: 7 }, "chat", { messages })).block).toEqual({
|
|
129
|
+
type: "chat",
|
|
130
|
+
messages: [{ side: "other", text: "Let me check." }],
|
|
131
|
+
});
|
|
132
|
+
for (const typing of ["agent", true, null, ""]) {
|
|
133
|
+
expect(chat(annotate({ messages, typing }, "chat")).typing).toBeUndefined();
|
|
134
|
+
}
|
|
135
|
+
// The messages-alone form has no block options to carry it.
|
|
136
|
+
expect(chat(annotate(messages, "chat")).typing).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("annotate chat inline replies", () => {
|
|
141
|
+
test("threads a reply to the earlier message it names and leaves the rest alone", () => {
|
|
142
|
+
const { messages } = chat(
|
|
143
|
+
annotate(
|
|
144
|
+
[
|
|
145
|
+
{ id: "m1", side: "other", text: "Let me check." },
|
|
146
|
+
{ id: "m2", side: "self", text: "where is my order?", replyTo: "m1", new: true },
|
|
147
|
+
{ side: "other", text: "It ships today." },
|
|
148
|
+
],
|
|
149
|
+
"chat",
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
expect(messages[0]).toEqual({ id: "m1", side: "other", text: "Let me check." });
|
|
153
|
+
expect(messages[1]).toEqual({ id: "m2", side: "self", text: "where is my order?", replyTo: "m1", new: true });
|
|
154
|
+
// A message with neither carries neither key.
|
|
155
|
+
expect(messages[2]).toEqual({ side: "other", text: "It ships today." });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Both are the author's handles, carried as given: the dashboard sees
|
|
159
|
+
// the whole transcript and decides whether a `replyTo` names an earlier
|
|
160
|
+
// message, drawing an ordinary bubble when it does not. Only a value
|
|
161
|
+
// that is not a non-empty string is dropped.
|
|
162
|
+
test("drops an id or a replyTo that is not a non-empty string and carries the rest as given", () => {
|
|
163
|
+
const { messages } = chat(
|
|
164
|
+
annotate(
|
|
165
|
+
[
|
|
166
|
+
{ id: 42, side: "self", text: "a", replyTo: 7 },
|
|
167
|
+
{ id: "", side: "self", text: "b", replyTo: "" },
|
|
168
|
+
{ id: null, side: "self", text: "c", replyTo: true },
|
|
169
|
+
{ id: "m4", side: "self", text: "d", replyTo: "nowhere" },
|
|
170
|
+
],
|
|
171
|
+
"chat",
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
expect(messages[0]).toEqual({ side: "self", text: "a" });
|
|
175
|
+
expect(messages[1]).toEqual({ side: "self", text: "b" });
|
|
176
|
+
expect(messages[2]).toEqual({ side: "self", text: "c" });
|
|
177
|
+
expect(messages[3]).toEqual({ id: "m4", side: "self", text: "d", replyTo: "nowhere" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an id is cut like a body, and the replyTo that names it the same way", () => {
|
|
181
|
+
// Past the recorder's snippet cap, so the cut is visible.
|
|
182
|
+
const long = "m".repeat(300_000);
|
|
183
|
+
const { messages } = chat(
|
|
184
|
+
annotate(
|
|
185
|
+
[
|
|
186
|
+
{ id: long, side: "other", text: "Let me check." },
|
|
187
|
+
{ side: "self", text: "ok", replyTo: long },
|
|
188
|
+
],
|
|
189
|
+
"chat",
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
expect(messages[0]!.id).toBe(truncateUtf8(long).value);
|
|
193
|
+
expect(messages[0]!.id!.length).toBeLessThan(long.length);
|
|
194
|
+
expect(messages[1]!.replyTo).toBe(messages[0]!.id);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("replies ride along when the messages are passed as options", () => {
|
|
198
|
+
const messages: ChatMessageAnnotation[] = [
|
|
199
|
+
{ id: "m1", side: "other", text: "Done?" },
|
|
200
|
+
{ id: "m2", side: "self", text: "Yes.", replyTo: "m1" },
|
|
201
|
+
];
|
|
202
|
+
expect(chat(annotate({ id: 7 }, "chat", { title: "+46 70 123 45 67", messages })).messages).toEqual(messages);
|
|
203
|
+
});
|
|
204
|
+
});
|
package/src/annotate.ts
CHANGED
|
@@ -153,6 +153,14 @@ export interface ChatAnnotation {
|
|
|
153
153
|
* reads fine without it.
|
|
154
154
|
*/
|
|
155
155
|
title?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Who is writing right now — `self` the end user, `other` whoever they
|
|
158
|
+
* are talking to — drawn as the three-dot bubble at the end of the
|
|
159
|
+
* thread on that side. It is the thread's present tense: a call either
|
|
160
|
+
* shows it or not, so there is no `new` to mark. Any other value is
|
|
161
|
+
* nobody typing.
|
|
162
|
+
*/
|
|
163
|
+
typing?: "self" | "other";
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
/** One turn of a {@link ChatAnnotation}. */
|
|
@@ -178,6 +186,34 @@ export interface ChatMessageAnnotation {
|
|
|
178
186
|
* corner. A reaction that was removed simply stops being listed.
|
|
179
187
|
*/
|
|
180
188
|
reactions?: readonly ChatReactionAnnotation[];
|
|
189
|
+
/**
|
|
190
|
+
* Delivery status, as the provider reported it: drawn as the small
|
|
191
|
+
* caption under the bubble ("Delivered", "Read"), on the bubble's own
|
|
192
|
+
* side. Any other value is left off — a caption, unlike a turn, can be
|
|
193
|
+
* dropped without the transcript lying.
|
|
194
|
+
*/
|
|
195
|
+
status?: "delivered" | "read";
|
|
196
|
+
/**
|
|
197
|
+
* When, as the app would show it ("23:14"): free text drawn after the
|
|
198
|
+
* status ("Read 23:14"). Rides with `status`; alone it has nothing to
|
|
199
|
+
* qualify and is dropped.
|
|
200
|
+
*/
|
|
201
|
+
statusAt?: string;
|
|
202
|
+
/**
|
|
203
|
+
* The author's handle for this message — the provider's message id,
|
|
204
|
+
* usually — so a later message can reply to it by name. Any non-empty
|
|
205
|
+
* string; the dashboard never draws it.
|
|
206
|
+
*/
|
|
207
|
+
id?: string;
|
|
208
|
+
/**
|
|
209
|
+
* The `id` of an earlier message in this transcript that this one
|
|
210
|
+
* replies to, drawn the way a messaging app threads a reply: an excerpt
|
|
211
|
+
* of that message quoted above this bubble, on this bubble's side. One
|
|
212
|
+
* that names no earlier message of the same call — an unknown id, a
|
|
213
|
+
* later message, the message itself — draws nothing extra, and the
|
|
214
|
+
* bubble reads as an ordinary message.
|
|
215
|
+
*/
|
|
216
|
+
replyTo?: string;
|
|
181
217
|
}
|
|
182
218
|
|
|
183
219
|
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
@@ -326,12 +362,14 @@ function lowerChat(
|
|
|
326
362
|
const source: unknown = deepUnwrap(options ?? value);
|
|
327
363
|
let list: unknown;
|
|
328
364
|
let title: string | undefined;
|
|
365
|
+
let typing: "self" | "other" | undefined;
|
|
329
366
|
if (Array.isArray(source)) {
|
|
330
367
|
list = source;
|
|
331
368
|
} else {
|
|
332
369
|
const bag = fields(source);
|
|
333
370
|
list = bag.messages;
|
|
334
371
|
title = text(bag.title) || undefined;
|
|
372
|
+
typing = typingOf(bag.typing);
|
|
335
373
|
}
|
|
336
374
|
// An empty conversation is a real state (nothing said yet) and renders as
|
|
337
375
|
// one; a value with no `messages` at all is the author's mistake.
|
|
@@ -343,7 +381,9 @@ function lowerChat(
|
|
|
343
381
|
return {
|
|
344
382
|
kind: "chat",
|
|
345
383
|
title,
|
|
346
|
-
blocks: [
|
|
384
|
+
blocks: [
|
|
385
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
386
|
+
],
|
|
347
387
|
};
|
|
348
388
|
}
|
|
349
389
|
|
|
@@ -358,9 +398,36 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
|
|
|
358
398
|
if (src.new === true) msg.new = true;
|
|
359
399
|
const reactions = reactionsOf(src.reactions);
|
|
360
400
|
if (reactions.length > 0) msg.reactions = reactions;
|
|
401
|
+
// A status caption, unlike a turn, can be left off without the
|
|
402
|
+
// transcript lying, so one that isn't `delivered` or `read` is dropped
|
|
403
|
+
// rather than guessed at. Its time rides with it: free text, truncated
|
|
404
|
+
// like a body, and meaningless without a status to qualify.
|
|
405
|
+
const status = src.status;
|
|
406
|
+
if (status === "delivered" || status === "read") {
|
|
407
|
+
msg.status = status;
|
|
408
|
+
if (typeof src.statusAt === "string" && src.statusAt !== "") {
|
|
409
|
+
msg.statusAt = truncateUtf8(src.statusAt).value;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// A reply threads to an earlier message by the handle its author gave
|
|
413
|
+
// it, so both ride through as given — a non-empty string, cut like a
|
|
414
|
+
// body — and anything else is dropped. Whether `replyTo` names an
|
|
415
|
+
// earlier message of the transcript is the dashboard's to decide: it
|
|
416
|
+
// draws the quote when it can and an ordinary bubble when it cannot.
|
|
417
|
+
if (typeof src.id === "string" && src.id !== "") msg.id = truncateUtf8(src.id).value;
|
|
418
|
+
if (typeof src.replyTo === "string" && src.replyTo !== "") {
|
|
419
|
+
msg.replyTo = truncateUtf8(src.replyTo).value;
|
|
420
|
+
}
|
|
361
421
|
return msg;
|
|
362
422
|
}
|
|
363
423
|
|
|
424
|
+
/** The block's `typing`: `self` or `other`, else nobody. The thread's
|
|
425
|
+
* present tense is a decoration, not a turn, so a value that isn't one of
|
|
426
|
+
* the two draws nothing rather than guessing at who. */
|
|
427
|
+
function typingOf(v: unknown): "self" | "other" | undefined {
|
|
428
|
+
return v === "self" || v === "other" ? v : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
364
431
|
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
365
432
|
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
366
433
|
* the other party's — and only one with nothing to draw (no string
|
package/src/recorder.ts
CHANGED
|
@@ -882,7 +882,9 @@ export type StepBlock =
|
|
|
882
882
|
| { type: "kv"; rows: { label: string; value?: string; error?: boolean }[]; label?: string }
|
|
883
883
|
| { type: "table"; columns: string[]; rows: unknown[][] }
|
|
884
884
|
| { type: "htmlSandbox"; html: string; label?: string }
|
|
885
|
-
|
|
885
|
+
// `typing` is the thread's present tense: whoever is writing right now
|
|
886
|
+
// gets the three-dot bubble at the end of the thread, on their side.
|
|
887
|
+
| { type: "chat"; messages: ChatBlockMessage[]; label?: string; typing?: "self" | "other" }
|
|
886
888
|
// A disclosure. For what a step has but a reader does not want on every
|
|
887
889
|
// step — a connection's protocol version, session id, redacted token.
|
|
888
890
|
| { type: "details"; summary: string; blocks: StepBlock[] };
|
|
@@ -902,6 +904,23 @@ export interface ChatBlockMessage {
|
|
|
902
904
|
* corner, on the side opposite the sender. Absent and empty read the
|
|
903
905
|
* same: nothing to draw. */
|
|
904
906
|
reactions?: ChatBlockReaction[];
|
|
907
|
+
/** Delivery status — the caption a messaging app shows under a bubble
|
|
908
|
+
* ("Delivered", "Read"), on the bubble's own side. Absent draws
|
|
909
|
+
* nothing. */
|
|
910
|
+
status?: "delivered" | "read";
|
|
911
|
+
/** When the status happened, as the app would show it ("23:14"): free
|
|
912
|
+
* text drawn after the status caption ("Read 23:14"). Rides with
|
|
913
|
+
* `status`; alone it has nothing to qualify. */
|
|
914
|
+
statusAt?: string;
|
|
915
|
+
/** The author's handle for this message — the provider's message id,
|
|
916
|
+
* usually — which a later message's `replyTo` names. Never drawn.
|
|
917
|
+
* Absent when the author gave none. */
|
|
918
|
+
id?: string;
|
|
919
|
+
/** The `id` of an earlier message in the same block this one replies
|
|
920
|
+
* to, drawn as a quoted excerpt of that message above the bubble, the
|
|
921
|
+
* way a messaging app threads a reply. One that names no earlier
|
|
922
|
+
* message draws nothing extra. */
|
|
923
|
+
replyTo?: string;
|
|
905
924
|
}
|
|
906
925
|
|
|
907
926
|
/** One reaction on a {@link ChatBlockMessage}. */
|