@checkstack/ai-frontend 0.1.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/CHANGELOG.md +75 -0
- package/package.json +32 -0
- package/src/components/AppliedCardView.tsx +36 -0
- package/src/components/ConfirmCardView.tsx +117 -0
- package/src/components/DiffView.tsx +84 -0
- package/src/components/SideBySideDiff.tsx +120 -0
- package/src/index.tsx +22 -0
- package/src/lib/chat-state.test.ts +213 -0
- package/src/lib/chat-state.ts +231 -0
- package/src/lib/line-diff.test.ts +87 -0
- package/src/lib/line-diff.ts +206 -0
- package/src/lib/mode-toggle.logic.test.ts +64 -0
- package/src/lib/mode-toggle.logic.ts +57 -0
- package/src/lib/model-options.logic.test.ts +55 -0
- package/src/lib/model-options.logic.ts +31 -0
- package/src/lib/new-chat.logic.test.ts +84 -0
- package/src/lib/new-chat.logic.ts +62 -0
- package/src/lib/stream-parser.test.ts +241 -0
- package/src/lib/stream-parser.ts +286 -0
- package/src/lib/use-chat-turn.ts +163 -0
- package/src/pages/ChatPage.tsx +661 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free line diff for the chat confirm/applied cards, modelled on the
|
|
3
|
+
* GitHub split (side-by-side) diff. Produces aligned rows carrying:
|
|
4
|
+
* - old/new LINE NUMBERS (gutters),
|
|
5
|
+
* - the per-side text split into SEGMENTS, where the exact changed tokens of an
|
|
6
|
+
* edited line are flagged `emphasis: true` (GitHub's intra-line word highlight,
|
|
7
|
+
* the thing that makes a one-character change actually readable).
|
|
8
|
+
*
|
|
9
|
+
* Lightweight on purpose: an LCS over lines, and a second LCS over word-tokens
|
|
10
|
+
* for the changed pairs - no Monaco/editor stack pulled into the chat.
|
|
11
|
+
* Unit-testable under `bun test` (no DOM).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A run of text on one side of a line; `emphasis` marks the changed tokens. */
|
|
15
|
+
export interface DiffSegment {
|
|
16
|
+
text: string;
|
|
17
|
+
emphasis: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One rendered row of a side-by-side diff. `null` means "no line on this side". */
|
|
21
|
+
export interface DiffLine {
|
|
22
|
+
/** 1-based old-file line number, or null when this row has no left line. */
|
|
23
|
+
leftNo: number | null;
|
|
24
|
+
/** 1-based new-file line number, or null when this row has no right line. */
|
|
25
|
+
rightNo: number | null;
|
|
26
|
+
left: DiffSegment[] | null;
|
|
27
|
+
right: DiffSegment[] | null;
|
|
28
|
+
/**
|
|
29
|
+
* - `equal`: unchanged line (both sides identical)
|
|
30
|
+
* - `changed`: a removed line paired with an added line (an edit)
|
|
31
|
+
* - `removed`: a line only on the left (deleted)
|
|
32
|
+
* - `added`: a line only on the right (inserted)
|
|
33
|
+
*/
|
|
34
|
+
kind: "equal" | "changed" | "removed" | "added";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Op = { type: "equal" | "del" | "ins"; text: string };
|
|
38
|
+
|
|
39
|
+
/** LCS suffix-DP over two token arrays: `dp[i][j]` = LCS length of a[i:], b[j:]. */
|
|
40
|
+
function lcsLengths(a: string[], b: string[]): number[][] {
|
|
41
|
+
const m = a.length;
|
|
42
|
+
const n = b.length;
|
|
43
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
|
44
|
+
Array.from({ length: n + 1 }, () => 0),
|
|
45
|
+
);
|
|
46
|
+
for (let i = m - 1; i >= 0; i -= 1) {
|
|
47
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
48
|
+
dp[i][j] =
|
|
49
|
+
a[i] === b[j]
|
|
50
|
+
? dp[i + 1][j + 1] + 1
|
|
51
|
+
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return dp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Walk the LCS table into an ordered equal/del/ins op list over two sequences. */
|
|
58
|
+
function sequenceOps(a: string[], b: string[]): Op[] {
|
|
59
|
+
const dp = lcsLengths(a, b);
|
|
60
|
+
const ops: Op[] = [];
|
|
61
|
+
let i = 0;
|
|
62
|
+
let j = 0;
|
|
63
|
+
while (i < a.length && j < b.length) {
|
|
64
|
+
if (a[i] === b[j]) {
|
|
65
|
+
ops.push({ type: "equal", text: a[i] });
|
|
66
|
+
i += 1;
|
|
67
|
+
j += 1;
|
|
68
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
69
|
+
ops.push({ type: "del", text: a[i] });
|
|
70
|
+
i += 1;
|
|
71
|
+
} else {
|
|
72
|
+
ops.push({ type: "ins", text: b[j] });
|
|
73
|
+
j += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
while (i < a.length) {
|
|
77
|
+
ops.push({ type: "del", text: a[i] });
|
|
78
|
+
i += 1;
|
|
79
|
+
}
|
|
80
|
+
while (j < b.length) {
|
|
81
|
+
ops.push({ type: "ins", text: b[j] });
|
|
82
|
+
j += 1;
|
|
83
|
+
}
|
|
84
|
+
return ops;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Split a line into word / whitespace / punctuation tokens for the word diff. */
|
|
88
|
+
function tokenize(line: string): string[] {
|
|
89
|
+
return line.match(/\w+|\s+|[^\s\w]+/g) ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Append text to a side, merging into the previous run when emphasis matches. */
|
|
93
|
+
function pushSegment(target: DiffSegment[], text: string, emphasis: boolean): void {
|
|
94
|
+
const last = target.at(-1);
|
|
95
|
+
if (last && last.emphasis === emphasis) {
|
|
96
|
+
last.text += text;
|
|
97
|
+
} else {
|
|
98
|
+
target.push({ text, emphasis });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Word-level diff of one edited line: returns the left (before) and right (after)
|
|
104
|
+
* segment runs, with the tokens unique to each side flagged `emphasis: true`.
|
|
105
|
+
*/
|
|
106
|
+
function wordSegments(
|
|
107
|
+
before: string,
|
|
108
|
+
after: string,
|
|
109
|
+
): { left: DiffSegment[]; right: DiffSegment[] } {
|
|
110
|
+
const ops = sequenceOps(tokenize(before), tokenize(after));
|
|
111
|
+
const left: DiffSegment[] = [];
|
|
112
|
+
const right: DiffSegment[] = [];
|
|
113
|
+
for (const op of ops) {
|
|
114
|
+
if (op.type === "equal") {
|
|
115
|
+
pushSegment(left, op.text, false);
|
|
116
|
+
pushSegment(right, op.text, false);
|
|
117
|
+
} else if (op.type === "del") {
|
|
118
|
+
pushSegment(left, op.text, true);
|
|
119
|
+
} else {
|
|
120
|
+
pushSegment(right, op.text, true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { left, right };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A whole line with no intra-line emphasis (equal/added/removed rows). */
|
|
127
|
+
function plain(text: string): DiffSegment[] {
|
|
128
|
+
return [{ text, emphasis: false }];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute the side-by-side rows for a before -> after text change. A run of
|
|
133
|
+
* deletions immediately followed by insertions is zipped index-by-index into
|
|
134
|
+
* `changed` rows (old line left, new line right, with word-level emphasis);
|
|
135
|
+
* leftover deletions/insertions become `removed`/`added` rows. Old/new line
|
|
136
|
+
* numbers are tracked across the walk for the gutters.
|
|
137
|
+
*/
|
|
138
|
+
export function computeLineDiff({
|
|
139
|
+
before,
|
|
140
|
+
after,
|
|
141
|
+
}: {
|
|
142
|
+
before: string;
|
|
143
|
+
after: string;
|
|
144
|
+
}): DiffLine[] {
|
|
145
|
+
const ops = sequenceOps(before.split("\n"), after.split("\n"));
|
|
146
|
+
const rows: DiffLine[] = [];
|
|
147
|
+
let leftNo = 0;
|
|
148
|
+
let rightNo = 0;
|
|
149
|
+
let dels: string[] = [];
|
|
150
|
+
let inss: string[] = [];
|
|
151
|
+
|
|
152
|
+
const flush = (): void => {
|
|
153
|
+
const pairs = Math.max(dels.length, inss.length);
|
|
154
|
+
for (let k = 0; k < pairs; k += 1) {
|
|
155
|
+
const l = k < dels.length ? dels[k] : null;
|
|
156
|
+
const r = k < inss.length ? inss[k] : null;
|
|
157
|
+
if (l === null) {
|
|
158
|
+
rightNo += 1;
|
|
159
|
+
rows.push({
|
|
160
|
+
leftNo: null,
|
|
161
|
+
rightNo,
|
|
162
|
+
left: null,
|
|
163
|
+
right: plain(r ?? ""),
|
|
164
|
+
kind: "added",
|
|
165
|
+
});
|
|
166
|
+
} else if (r === null) {
|
|
167
|
+
leftNo += 1;
|
|
168
|
+
rows.push({
|
|
169
|
+
leftNo,
|
|
170
|
+
rightNo: null,
|
|
171
|
+
left: plain(l),
|
|
172
|
+
right: null,
|
|
173
|
+
kind: "removed",
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
leftNo += 1;
|
|
177
|
+
rightNo += 1;
|
|
178
|
+
const { left, right } = wordSegments(l, r);
|
|
179
|
+
rows.push({ leftNo, rightNo, left, right, kind: "changed" });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
dels = [];
|
|
183
|
+
inss = [];
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const op of ops) {
|
|
187
|
+
if (op.type === "equal") {
|
|
188
|
+
flush();
|
|
189
|
+
leftNo += 1;
|
|
190
|
+
rightNo += 1;
|
|
191
|
+
rows.push({
|
|
192
|
+
leftNo,
|
|
193
|
+
rightNo,
|
|
194
|
+
left: plain(op.text),
|
|
195
|
+
right: plain(op.text),
|
|
196
|
+
kind: "equal",
|
|
197
|
+
});
|
|
198
|
+
} else if (op.type === "del") {
|
|
199
|
+
dels.push(op.text);
|
|
200
|
+
} else {
|
|
201
|
+
inss.push(op.text);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
flush();
|
|
205
|
+
return rows;
|
|
206
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
PERMISSION_MODE_OPTIONS,
|
|
4
|
+
buildModeUpdate,
|
|
5
|
+
deriveModeToggleValue,
|
|
6
|
+
} from "./mode-toggle.logic";
|
|
7
|
+
|
|
8
|
+
describe("deriveModeToggleValue", () => {
|
|
9
|
+
test("uses the conversation's mode when valid", () => {
|
|
10
|
+
expect(deriveModeToggleValue({ conversationMode: "auto" })).toBe("auto");
|
|
11
|
+
expect(deriveModeToggleValue({ conversationMode: "approve" })).toBe(
|
|
12
|
+
"approve",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("falls back to the safe default (approve) for null/undefined/invalid", () => {
|
|
17
|
+
expect(deriveModeToggleValue({ conversationMode: null })).toBe("approve");
|
|
18
|
+
expect(deriveModeToggleValue({ conversationMode: undefined })).toBe(
|
|
19
|
+
"approve",
|
|
20
|
+
);
|
|
21
|
+
expect(deriveModeToggleValue({ conversationMode: "bogus" })).toBe("approve");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("buildModeUpdate", () => {
|
|
26
|
+
test("returns the owner-scoped update payload on a real change", () => {
|
|
27
|
+
expect(
|
|
28
|
+
buildModeUpdate({
|
|
29
|
+
conversationId: "c1",
|
|
30
|
+
currentMode: "approve",
|
|
31
|
+
nextMode: "auto",
|
|
32
|
+
}),
|
|
33
|
+
).toEqual({ id: "c1", permissionMode: "auto" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns undefined when there is no conversation to update", () => {
|
|
37
|
+
expect(
|
|
38
|
+
buildModeUpdate({
|
|
39
|
+
conversationId: undefined,
|
|
40
|
+
currentMode: "approve",
|
|
41
|
+
nextMode: "auto",
|
|
42
|
+
}),
|
|
43
|
+
).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns undefined for a no-op (mode unchanged)", () => {
|
|
47
|
+
expect(
|
|
48
|
+
buildModeUpdate({
|
|
49
|
+
conversationId: "c1",
|
|
50
|
+
currentMode: "auto",
|
|
51
|
+
nextMode: "auto",
|
|
52
|
+
}),
|
|
53
|
+
).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("PERMISSION_MODE_OPTIONS", () => {
|
|
58
|
+
test("offers exactly approve + auto, in that order", () => {
|
|
59
|
+
expect(PERMISSION_MODE_OPTIONS.map((o) => o.value)).toEqual([
|
|
60
|
+
"approve",
|
|
61
|
+
"auto",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AiPermissionModeSchema,
|
|
3
|
+
DEFAULT_PERMISSION_MODE,
|
|
4
|
+
type AiPermissionMode,
|
|
5
|
+
} from "@checkstack/ai-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pure, DOM-free helpers for the Approve/Auto permission-mode toggle in the chat
|
|
9
|
+
* header (Phase 4). The toggle state derives from the loaded conversation's
|
|
10
|
+
* `permissionMode`; persistence goes through `updateConversation`. These helpers
|
|
11
|
+
* encode that derivation + payload shaping so they are unit-testable under
|
|
12
|
+
* `bun test` without rendering the component.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The two selectable permission modes, in display order, with labels. */
|
|
16
|
+
export const PERMISSION_MODE_OPTIONS: ReadonlyArray<{
|
|
17
|
+
value: AiPermissionMode;
|
|
18
|
+
label: string;
|
|
19
|
+
}> = [
|
|
20
|
+
{ value: "approve", label: "Approve" },
|
|
21
|
+
{ value: "auto", label: "Auto" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derive the toggle's current value from a loaded conversation's
|
|
26
|
+
* `permissionMode`. Falls back to the safe default (`approve`) when no
|
|
27
|
+
* conversation is loaded or the stored value is missing/unrecognized (defensive
|
|
28
|
+
* against a legacy/corrupt row), so the toggle never renders an invalid state.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveModeToggleValue({
|
|
31
|
+
conversationMode,
|
|
32
|
+
}: {
|
|
33
|
+
conversationMode: string | null | undefined;
|
|
34
|
+
}): AiPermissionMode {
|
|
35
|
+
const parsed = AiPermissionModeSchema.safeParse(conversationMode);
|
|
36
|
+
return parsed.success ? parsed.data : DEFAULT_PERMISSION_MODE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shape the `updateConversation` payload for a mode change. Returns `undefined`
|
|
41
|
+
* when there is no conversation to update OR the requested mode already matches
|
|
42
|
+
* the current one (avoid a no-op write). Otherwise returns the owner-scoped
|
|
43
|
+
* update input the mutation consumes.
|
|
44
|
+
*/
|
|
45
|
+
export function buildModeUpdate({
|
|
46
|
+
conversationId,
|
|
47
|
+
currentMode,
|
|
48
|
+
nextMode,
|
|
49
|
+
}: {
|
|
50
|
+
conversationId: string | undefined;
|
|
51
|
+
currentMode: AiPermissionMode;
|
|
52
|
+
nextMode: AiPermissionMode;
|
|
53
|
+
}): { id: string; permissionMode: AiPermissionMode } | undefined {
|
|
54
|
+
if (!conversationId) return undefined;
|
|
55
|
+
if (currentMode === nextMode) return undefined;
|
|
56
|
+
return { id: conversationId, permissionMode: nextMode };
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildModelOptions } from "./model-options.logic";
|
|
3
|
+
|
|
4
|
+
describe("buildModelOptions", () => {
|
|
5
|
+
test("returns [defaultModel] when there are no availableModels", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildModelOptions({ defaultModel: "gpt-4o", availableModels: [] }),
|
|
8
|
+
).toEqual(["gpt-4o"]);
|
|
9
|
+
expect(
|
|
10
|
+
buildModelOptions({ defaultModel: "gpt-4o", availableModels: undefined }),
|
|
11
|
+
).toEqual(["gpt-4o"]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("puts the default model first", () => {
|
|
15
|
+
expect(
|
|
16
|
+
buildModelOptions({
|
|
17
|
+
defaultModel: "gpt-4o",
|
|
18
|
+
availableModels: ["gpt-4o-mini", "o3"],
|
|
19
|
+
}),
|
|
20
|
+
).toEqual(["gpt-4o", "gpt-4o-mini", "o3"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("de-duplicates the default when it is also in availableModels", () => {
|
|
24
|
+
expect(
|
|
25
|
+
buildModelOptions({
|
|
26
|
+
defaultModel: "gpt-4o",
|
|
27
|
+
availableModels: ["gpt-4o", "gpt-4o-mini"],
|
|
28
|
+
}),
|
|
29
|
+
).toEqual(["gpt-4o", "gpt-4o-mini"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("de-duplicates repeated availableModels entries", () => {
|
|
33
|
+
expect(
|
|
34
|
+
buildModelOptions({
|
|
35
|
+
defaultModel: "a",
|
|
36
|
+
availableModels: ["b", "b", "c", "a"],
|
|
37
|
+
}),
|
|
38
|
+
).toEqual(["a", "b", "c"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns an empty list when there is no default and no models", () => {
|
|
42
|
+
expect(
|
|
43
|
+
buildModelOptions({ defaultModel: undefined, availableModels: [] }),
|
|
44
|
+
).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("ignores an undefined default but keeps availableModels", () => {
|
|
48
|
+
expect(
|
|
49
|
+
buildModelOptions({
|
|
50
|
+
defaultModel: undefined,
|
|
51
|
+
availableModels: ["x", "y"],
|
|
52
|
+
}),
|
|
53
|
+
).toEqual(["x", "y"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free derivation of the chat model picker's options.
|
|
3
|
+
*
|
|
4
|
+
* The dropdown must always offer the connection's `defaultModel` (the prior UI
|
|
5
|
+
* omitted it, so a connection's own default could be unselectable) followed by
|
|
6
|
+
* its `availableModels`, de-duplicated and with the default first. With no
|
|
7
|
+
* `availableModels` the result is just `[defaultModel]` so the picker stays a
|
|
8
|
+
* tidy Select instead of falling back to a free-text field.
|
|
9
|
+
*/
|
|
10
|
+
export function buildModelOptions({
|
|
11
|
+
defaultModel,
|
|
12
|
+
availableModels,
|
|
13
|
+
}: {
|
|
14
|
+
/** The connection's default model id (undefined before an integration loads). */
|
|
15
|
+
defaultModel: string | undefined;
|
|
16
|
+
/** The connection's allowlisted model ids (may be empty/undefined). */
|
|
17
|
+
availableModels: readonly string[] | undefined;
|
|
18
|
+
}): string[] {
|
|
19
|
+
const ordered: string[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
const add = (value: string | undefined) => {
|
|
22
|
+
if (!value || seen.has(value)) return;
|
|
23
|
+
seen.add(value);
|
|
24
|
+
ordered.push(value);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Default first, then the allowlist, de-duplicated.
|
|
28
|
+
add(defaultModel);
|
|
29
|
+
for (const model of availableModels ?? []) add(model);
|
|
30
|
+
return ordered;
|
|
31
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isEmptyUntitledChat,
|
|
4
|
+
decideNewChatAction,
|
|
5
|
+
} from "./new-chat.logic";
|
|
6
|
+
|
|
7
|
+
describe("isEmptyUntitledChat", () => {
|
|
8
|
+
test("true for an untitled chat with no messages", () => {
|
|
9
|
+
expect(
|
|
10
|
+
isEmptyUntitledChat({
|
|
11
|
+
conversation: { id: "c1", title: null },
|
|
12
|
+
messages: [],
|
|
13
|
+
}),
|
|
14
|
+
).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("true when the title is blank/whitespace only", () => {
|
|
18
|
+
expect(
|
|
19
|
+
isEmptyUntitledChat({
|
|
20
|
+
conversation: { id: "c1", title: " " },
|
|
21
|
+
messages: [],
|
|
22
|
+
}),
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("false once the chat has a real title", () => {
|
|
27
|
+
expect(
|
|
28
|
+
isEmptyUntitledChat({
|
|
29
|
+
conversation: { id: "c1", title: "Open incidents" },
|
|
30
|
+
messages: [],
|
|
31
|
+
}),
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("false once the chat has a non-blank message", () => {
|
|
36
|
+
expect(
|
|
37
|
+
isEmptyUntitledChat({
|
|
38
|
+
conversation: { id: "c1", title: null },
|
|
39
|
+
messages: [{ text: "hello" }],
|
|
40
|
+
}),
|
|
41
|
+
).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("blank-only messages do not count as content", () => {
|
|
45
|
+
expect(
|
|
46
|
+
isEmptyUntitledChat({
|
|
47
|
+
conversation: { id: "c1", title: null },
|
|
48
|
+
messages: [{ text: " " }],
|
|
49
|
+
}),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("false when there is no open conversation", () => {
|
|
54
|
+
expect(
|
|
55
|
+
isEmptyUntitledChat({ conversation: undefined, messages: [] }),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("decideNewChatAction", () => {
|
|
61
|
+
test("reuses the open empty untitled draft instead of creating a duplicate", () => {
|
|
62
|
+
expect(
|
|
63
|
+
decideNewChatAction({
|
|
64
|
+
current: { id: "c1", title: null },
|
|
65
|
+
messages: [],
|
|
66
|
+
}),
|
|
67
|
+
).toEqual({ kind: "reuse", conversationId: "c1" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("creates a fresh conversation when the open one has content", () => {
|
|
71
|
+
expect(
|
|
72
|
+
decideNewChatAction({
|
|
73
|
+
current: { id: "c1", title: "Titled" },
|
|
74
|
+
messages: [],
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({ kind: "create" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("creates a fresh conversation when none is open", () => {
|
|
80
|
+
expect(
|
|
81
|
+
decideNewChatAction({ current: undefined, messages: [] }),
|
|
82
|
+
).toEqual({ kind: "create" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free helpers for the "New chat" sidebar action (Fix 1).
|
|
3
|
+
*
|
|
4
|
+
* Clicking "New chat" should NOT spawn a fresh empty conversation every time:
|
|
5
|
+
* if the conversation currently open is itself an empty, untitled draft, the
|
|
6
|
+
* click should just reuse it (and keep it highlighted) instead of creating
|
|
7
|
+
* another "Untitled chat" row. These helpers encode that decision so it is
|
|
8
|
+
* unit-testable under `bun test` without rendering the component.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The minimal conversation shape the new-chat decision needs. */
|
|
12
|
+
export interface NewChatConversation {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The minimal message shape the new-chat decision needs. */
|
|
18
|
+
export interface NewChatMessage {
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when a conversation is an empty, untitled draft: no (non-blank) title and
|
|
24
|
+
* no messages. Such a conversation is indistinguishable from a brand-new one, so
|
|
25
|
+
* "New chat" can reuse it rather than create a duplicate.
|
|
26
|
+
*/
|
|
27
|
+
export function isEmptyUntitledChat({
|
|
28
|
+
conversation,
|
|
29
|
+
messages,
|
|
30
|
+
}: {
|
|
31
|
+
conversation: NewChatConversation | undefined;
|
|
32
|
+
messages: ReadonlyArray<NewChatMessage>;
|
|
33
|
+
}): boolean {
|
|
34
|
+
if (!conversation) return false;
|
|
35
|
+
const hasTitle = (conversation.title ?? "").trim().length > 0;
|
|
36
|
+
const hasMessages = messages.some((m) => m.text.trim().length > 0);
|
|
37
|
+
return !hasTitle && !hasMessages;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide what "New chat" should do given the currently-open conversation and its
|
|
42
|
+
* messages:
|
|
43
|
+
* - `{ kind: "reuse", conversationId }` when the open chat is already an empty
|
|
44
|
+
* untitled draft (avoid spawning a duplicate),
|
|
45
|
+
* - `{ kind: "create" }` otherwise (create a fresh conversation).
|
|
46
|
+
*/
|
|
47
|
+
export type NewChatAction =
|
|
48
|
+
| { kind: "reuse"; conversationId: string }
|
|
49
|
+
| { kind: "create" };
|
|
50
|
+
|
|
51
|
+
export function decideNewChatAction({
|
|
52
|
+
current,
|
|
53
|
+
messages,
|
|
54
|
+
}: {
|
|
55
|
+
current: NewChatConversation | undefined;
|
|
56
|
+
messages: ReadonlyArray<NewChatMessage>;
|
|
57
|
+
}): NewChatAction {
|
|
58
|
+
if (current && isEmptyUntitledChat({ conversation: current, messages })) {
|
|
59
|
+
return { kind: "reuse", conversationId: current.id };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "create" };
|
|
62
|
+
}
|