kanna-code 0.18.0 → 0.19.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.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-CLqK0AWC.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BvhI1JYs.css">
8
+ <script type="module" crossorigin src="/assets/index-lgfU0zw2.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C952_xJD.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.18.0",
4
+ "version": "0.19.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -127,6 +127,10 @@ describe("attachment prompt helpers", () => {
127
127
 
128
128
  describe("AgentCoordinator codex integration", () => {
129
129
  test("generates a chat title in the background on the first user message", async () => {
130
+ let releaseTitle!: () => void
131
+ const titleGate = new Promise<void>((resolve) => {
132
+ releaseTitle = resolve
133
+ })
130
134
  const fakeCodexManager = {
131
135
  async startSession() {},
132
136
  async startTurn(): Promise<HarnessTurn> {
@@ -169,7 +173,14 @@ describe("AgentCoordinator codex integration", () => {
169
173
  store: store as never,
170
174
  onStateChange: () => {},
171
175
  codexManager: fakeCodexManager as never,
172
- generateTitle: async () => "Generated title",
176
+ generateTitle: async () => {
177
+ await titleGate
178
+ return {
179
+ title: "Generated title",
180
+ usedFallback: false,
181
+ failureMessage: null,
182
+ }
183
+ },
173
184
  })
174
185
 
175
186
  await coordinator.send({
@@ -180,6 +191,8 @@ describe("AgentCoordinator codex integration", () => {
180
191
  model: "gpt-5.4",
181
192
  })
182
193
 
194
+ expect(store.chat.title).toBe("first message")
195
+ releaseTitle()
183
196
  await waitFor(() => store.chat.title === "Generated title")
184
197
  expect(store.messages[0]?.kind).toBe("user_prompt")
185
198
  })
@@ -233,7 +246,11 @@ describe("AgentCoordinator codex integration", () => {
233
246
  codexManager: fakeCodexManager as never,
234
247
  generateTitle: async () => {
235
248
  await titleGate
236
- return "Generated title"
249
+ return {
250
+ title: "Generated title",
251
+ usedFallback: false,
252
+ failureMessage: null,
253
+ }
237
254
  },
238
255
  })
239
256
 
@@ -252,6 +269,76 @@ describe("AgentCoordinator codex integration", () => {
252
269
  expect(store.chat.title).toBe("Manual title")
253
270
  })
254
271
 
272
+ test("reports provider failure without a second rename after the optimistic title", async () => {
273
+ const fakeCodexManager = {
274
+ async startSession() {},
275
+ async startTurn(): Promise<HarnessTurn> {
276
+ async function* stream() {
277
+ yield {
278
+ type: "transcript" as const,
279
+ entry: timestamped({
280
+ kind: "system_init",
281
+ provider: "codex",
282
+ model: "gpt-5.4",
283
+ tools: [],
284
+ agents: [],
285
+ slashCommands: [],
286
+ mcpServers: [],
287
+ }),
288
+ }
289
+ yield {
290
+ type: "transcript" as const,
291
+ entry: timestamped({
292
+ kind: "result",
293
+ subtype: "success",
294
+ isError: false,
295
+ durationMs: 0,
296
+ result: "",
297
+ }),
298
+ }
299
+ }
300
+
301
+ return {
302
+ provider: "codex",
303
+ stream: stream(),
304
+ interrupt: async () => {},
305
+ close: () => {},
306
+ }
307
+ },
308
+ }
309
+
310
+ const store = createFakeStore()
311
+ const backgroundErrors: string[] = []
312
+ const coordinator = new AgentCoordinator({
313
+ store: store as never,
314
+ onStateChange: () => {},
315
+ codexManager: fakeCodexManager as never,
316
+ generateTitle: async () => ({
317
+ title: "first message",
318
+ usedFallback: true,
319
+ failureMessage: "claude failed conversation title generation: Not authenticated",
320
+ }),
321
+ })
322
+ coordinator.setBackgroundErrorReporter((message) => {
323
+ backgroundErrors.push(message)
324
+ })
325
+
326
+ await coordinator.send({
327
+ type: "chat.send",
328
+ chatId: "chat-1",
329
+ provider: "codex",
330
+ content: "first message",
331
+ model: "gpt-5.4",
332
+ })
333
+
334
+ expect(store.chat.title).toBe("first message")
335
+ await waitFor(() => store.turnFinishedCount === 1)
336
+ expect(store.chat.title).toBe("first message")
337
+ expect(backgroundErrors).toEqual([
338
+ "[title-generation] chat chat-1 failed provider title generation: claude failed conversation title generation: Not authenticated",
339
+ ])
340
+ })
341
+
255
342
  test("binds codex provider and reuses the session token on later turns", async () => {
256
343
  const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
257
344
  const fakeCodexManager = {
@@ -11,7 +11,7 @@ import { normalizeToolCall } from "../shared/tools"
11
11
  import type { ClientCommand } from "../shared/protocol"
12
12
  import { EventStore } from "./event-store"
13
13
  import { CodexAppServerManager } from "./codex-app-server"
14
- import { generateTitleForChat } from "./generate-title"
14
+ import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
15
15
  import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
16
16
  import {
17
17
  codexServiceTierFromModelOptions,
@@ -21,6 +21,7 @@ import {
21
21
  normalizeServerModel,
22
22
  } from "./provider-catalog"
23
23
  import { resolveClaudeApiModelId } from "../shared/types"
24
+ import { fallbackTitleFromMessage } from "./generate-title"
24
25
 
25
26
  const CLAUDE_TOOLSET = [
26
27
  "Skill",
@@ -67,7 +68,7 @@ interface AgentCoordinatorArgs {
67
68
  store: EventStore
68
69
  onStateChange: () => void
69
70
  codexManager?: CodexAppServerManager
70
- generateTitle?: (messageContent: string, cwd: string) => Promise<string | null>
71
+ generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
71
72
  }
72
73
 
73
74
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
@@ -370,14 +371,19 @@ export class AgentCoordinator {
370
371
  private readonly store: EventStore
371
372
  private readonly onStateChange: () => void
372
373
  private readonly codexManager: CodexAppServerManager
373
- private readonly generateTitle: (messageContent: string, cwd: string) => Promise<string | null>
374
+ private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
375
+ private reportBackgroundError: ((message: string) => void) | null = null
374
376
  readonly activeTurns = new Map<string, ActiveTurn>()
375
377
 
376
378
  constructor(args: AgentCoordinatorArgs) {
377
379
  this.store = args.store
378
380
  this.onStateChange = args.onStateChange
379
381
  this.codexManager = args.codexManager ?? new CodexAppServerManager()
380
- this.generateTitle = args.generateTitle ?? generateTitleForChat
382
+ this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
383
+ }
384
+
385
+ setBackgroundErrorReporter(report: ((message: string) => void) | null) {
386
+ this.reportBackgroundError = report
381
387
  }
382
388
 
383
389
  getActiveStatuses() {
@@ -444,6 +450,11 @@ export class AgentCoordinator {
444
450
 
445
451
  const existingMessages = this.store.getMessages(args.chatId)
446
452
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
453
+ const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
454
+
455
+ if (optimisticTitle) {
456
+ await this.store.renameChat(args.chatId, optimisticTitle)
457
+ }
447
458
 
448
459
  if (args.appendUserPrompt) {
449
460
  await this.store.appendMessage(
@@ -459,7 +470,7 @@ export class AgentCoordinator {
459
470
  }
460
471
 
461
472
  if (shouldGenerateTitle) {
462
- void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
473
+ void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
463
474
  }
464
475
 
465
476
  const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
@@ -570,18 +581,26 @@ export class AgentCoordinator {
570
581
  return { chatId }
571
582
  }
572
583
 
573
- private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
584
+ private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
574
585
  try {
575
- const title = await this.generateTitle(messageContent, cwd)
576
- if (!title) return
586
+ const result = await this.generateTitle(messageContent, cwd)
587
+ if (result.failureMessage) {
588
+ this.reportBackgroundError?.(
589
+ `[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
590
+ )
591
+ }
592
+ if (!result.title || result.usedFallback) return
577
593
 
578
594
  const chat = this.store.requireChat(chatId)
579
- if (chat.title !== "New Chat") return
595
+ if (chat.title !== expectedCurrentTitle) return
580
596
 
581
- await this.store.renameChat(chatId, title)
597
+ await this.store.renameChat(chatId, result.title)
582
598
  this.onStateChange()
583
- } catch {
584
- // Ignore background title generation failures.
599
+ } catch (error) {
600
+ const message = error instanceof Error ? error.message : String(error)
601
+ this.reportBackgroundError?.(
602
+ `[title-generation] chat ${chatId} failed background title generation: ${message}`
603
+ )
585
604
  }
586
605
  }
587
606
 
@@ -65,6 +65,7 @@ describe("EventStore", () => {
65
65
  title: "Chat",
66
66
  createdAt: 1,
67
67
  updatedAt: 5,
68
+ unread: false,
68
69
  provider: null,
69
70
  planMode: false,
70
71
  sessionToken: null,
@@ -130,4 +131,68 @@ describe("EventStore", () => {
130
131
  expect(snapshot.messages).toBeUndefined()
131
132
  expect(existsSync(join(dataDir, "transcripts", `${chat.id}.jsonl`))).toBe(true)
132
133
  })
134
+
135
+ test("marks chats unread on completed turns and clears unread when marked read", async () => {
136
+ const dataDir = await createTempDataDir()
137
+ const store = new EventStore(dataDir)
138
+ await store.initialize()
139
+
140
+ const project = await store.openProject("/tmp/project")
141
+ const chat = await store.createChat(project.id)
142
+
143
+ expect(store.getChat(chat.id)?.unread).toBe(false)
144
+
145
+ await store.recordTurnFinished(chat.id)
146
+ expect(store.getChat(chat.id)?.unread).toBe(true)
147
+
148
+ await store.setChatReadState(chat.id, false)
149
+ expect(store.getChat(chat.id)?.unread).toBe(false)
150
+
151
+ await store.recordTurnFailed(chat.id, "boom")
152
+ expect(store.getChat(chat.id)?.unread).toBe(true)
153
+
154
+ await store.recordTurnCancelled(chat.id)
155
+ expect(store.getChat(chat.id)?.unread).toBe(true)
156
+
157
+ await store.compact()
158
+
159
+ const reloaded = new EventStore(dataDir)
160
+ await reloaded.initialize()
161
+ expect(reloaded.getChat(chat.id)?.unread).toBe(true)
162
+ })
163
+
164
+ test("loads chats without unread from older snapshots as read", async () => {
165
+ const dataDir = await createTempDataDir()
166
+ const snapshotPath = join(dataDir, "snapshot.json")
167
+
168
+ const snapshot = {
169
+ v: 2,
170
+ generatedAt: 10,
171
+ projects: [{
172
+ id: "project-1",
173
+ localPath: "/tmp/project",
174
+ title: "Project",
175
+ createdAt: 1,
176
+ updatedAt: 5,
177
+ }],
178
+ chats: [{
179
+ id: "chat-1",
180
+ projectId: "project-1",
181
+ title: "Chat",
182
+ createdAt: 1,
183
+ updatedAt: 5,
184
+ provider: null,
185
+ planMode: false,
186
+ sessionToken: null,
187
+ lastTurnOutcome: null,
188
+ }],
189
+ }
190
+
191
+ await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
192
+
193
+ const store = new EventStore(dataDir)
194
+ await store.initialize()
195
+
196
+ expect(store.getChat("chat-1")?.unread).toBe(false)
197
+ })
133
198
  })
@@ -105,7 +105,10 @@ export class EventStore {
105
105
  this.state.projectIdsByPath.set(project.localPath, project.id)
106
106
  }
107
107
  for (const chat of parsed.chats) {
108
- this.state.chatsById.set(chat.id, { ...chat })
108
+ this.state.chatsById.set(chat.id, {
109
+ ...chat,
110
+ unread: chat.unread ?? false,
111
+ })
109
112
  }
110
113
  if (parsed.messages?.length) {
111
114
  this.snapshotHasLegacyMessages = true
@@ -210,6 +213,7 @@ export class EventStore {
210
213
  title: event.title,
211
214
  createdAt: event.timestamp,
212
215
  updatedAt: event.timestamp,
216
+ unread: false,
213
217
  provider: null,
214
218
  planMode: false,
215
219
  sessionToken: null,
@@ -246,6 +250,13 @@ export class EventStore {
246
250
  chat.updatedAt = event.timestamp
247
251
  break
248
252
  }
253
+ case "chat_read_state_set": {
254
+ const chat = this.state.chatsById.get(event.chatId)
255
+ if (!chat) break
256
+ chat.unread = event.unread
257
+ chat.updatedAt = event.timestamp
258
+ break
259
+ }
249
260
  case "message_appended": {
250
261
  this.applyMessageMetadata(event.chatId, event.entry)
251
262
  const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
@@ -263,6 +274,7 @@ export class EventStore {
263
274
  const chat = this.state.chatsById.get(event.chatId)
264
275
  if (!chat) break
265
276
  chat.updatedAt = event.timestamp
277
+ chat.unread = true
266
278
  chat.lastTurnOutcome = "success"
267
279
  break
268
280
  }
@@ -270,6 +282,7 @@ export class EventStore {
270
282
  const chat = this.state.chatsById.get(event.chatId)
271
283
  if (!chat) break
272
284
  chat.updatedAt = event.timestamp
285
+ chat.unread = true
273
286
  chat.lastTurnOutcome = "failed"
274
287
  break
275
288
  }
@@ -438,6 +451,19 @@ export class EventStore {
438
451
  await this.append(this.chatsLogPath, event)
439
452
  }
440
453
 
454
+ async setChatReadState(chatId: string, unread: boolean) {
455
+ const chat = this.requireChat(chatId)
456
+ if (chat.unread === unread) return
457
+ const event: ChatEvent = {
458
+ v: STORE_VERSION,
459
+ type: "chat_read_state_set",
460
+ timestamp: Date.now(),
461
+ chatId,
462
+ unread,
463
+ }
464
+ await this.append(this.chatsLogPath, event)
465
+ }
466
+
441
467
  async appendMessage(chatId: string, entry: TranscriptEntry) {
442
468
  this.requireChat(chatId)
443
469
  const payload = `${JSON.stringify(entry)}\n`
@@ -11,6 +11,7 @@ export interface ChatRecord {
11
11
  createdAt: number
12
12
  updatedAt: number
13
13
  deletedAt?: number
14
+ unread: boolean
14
15
  provider: AgentProvider | null
15
16
  planMode: boolean
16
17
  sessionToken: string | null
@@ -82,6 +83,13 @@ export type ChatEvent =
82
83
  chatId: string
83
84
  planMode: boolean
84
85
  }
86
+ | {
87
+ v: 2
88
+ type: "chat_read_state_set"
89
+ timestamp: number
90
+ chatId: string
91
+ unread: boolean
92
+ }
85
93
 
86
94
  export type MessageEvent = {
87
95
  v: 2
@@ -16,12 +16,39 @@ function normalizeGeneratedTitle(value: unknown): string | null {
16
16
  return normalized
17
17
  }
18
18
 
19
+ export function fallbackTitleFromMessage(messageContent: string): string | null {
20
+ const normalized = messageContent.replace(/\s+/g, " ").trim()
21
+ if (!normalized) return null
22
+ if (normalized.length <= 35) return normalized
23
+ return `${normalized.slice(0, 35)}...`
24
+ }
25
+
26
+ export interface GenerateChatTitleResult {
27
+ title: string | null
28
+ usedFallback: boolean
29
+ failureMessage: string | null
30
+ }
31
+
32
+ function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
33
+ if (failures.length === 0) return null
34
+ return failures.map((failure) => failure.reason).join("; ")
35
+ }
36
+
19
37
  export async function generateTitleForChat(
20
38
  messageContent: string,
21
39
  cwd: string,
22
40
  adapter = new QuickResponseAdapter()
23
41
  ): Promise<string | null> {
24
- const result = await adapter.generateStructured<string>({
42
+ const result = await generateTitleForChatDetailed(messageContent, cwd, adapter)
43
+ return result.title
44
+ }
45
+
46
+ export async function generateTitleForChatDetailed(
47
+ messageContent: string,
48
+ cwd: string,
49
+ adapter = new QuickResponseAdapter()
50
+ ): Promise<GenerateChatTitleResult> {
51
+ const result = await adapter.generateStructuredWithDiagnostics<string>({
25
52
  cwd,
26
53
  task: "conversation title generation",
27
54
  prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
@@ -32,5 +59,18 @@ export async function generateTitleForChat(
32
59
  },
33
60
  })
34
61
 
35
- return result
62
+ if (result.value) {
63
+ return {
64
+ title: result.value,
65
+ usedFallback: false,
66
+ failureMessage: null,
67
+ }
68
+ }
69
+
70
+ const fallbackTitle = fallbackTitleFromMessage(messageContent)
71
+ return {
72
+ title: fallbackTitle,
73
+ usedFallback: true,
74
+ failureMessage: summarizeFailures(result.failures),
75
+ }
36
76
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { generateTitleForChat } from "./generate-title"
3
- import { QuickResponseAdapter } from "./quick-response"
2
+ import { fallbackTitleFromMessage, generateTitleForChat, generateTitleForChatDetailed } from "./generate-title"
3
+ import { getQuickResponseWorkspace, QuickResponseAdapter } from "./quick-response"
4
4
 
5
5
  describe("QuickResponseAdapter", () => {
6
6
  test("returns the Claude structured result when it validates", async () => {
@@ -85,6 +85,83 @@ describe("QuickResponseAdapter", () => {
85
85
 
86
86
  expect(result).toBe("Codex title")
87
87
  })
88
+
89
+ test("uses the Kanna app data root as the quick-response workspace", async () => {
90
+ const previousProfile = process.env.KANNA_RUNTIME_PROFILE
91
+ process.env.KANNA_RUNTIME_PROFILE = "dev"
92
+
93
+ try {
94
+ let claudeCwd = ""
95
+ const adapter = new QuickResponseAdapter({
96
+ runClaudeStructured: async (args) => {
97
+ claudeCwd = args.cwd
98
+ return { title: "Claude title" }
99
+ },
100
+ })
101
+
102
+ await adapter.generateStructured({
103
+ cwd: "/tmp/project",
104
+ task: "title generation",
105
+ prompt: "Generate a title",
106
+ schema: {
107
+ type: "object",
108
+ properties: {
109
+ title: { type: "string" },
110
+ },
111
+ required: ["title"],
112
+ additionalProperties: false,
113
+ },
114
+ parse: (value) => {
115
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
116
+ return typeof output.title === "string" ? output.title : null
117
+ },
118
+ })
119
+
120
+ expect(claudeCwd).toBe(getQuickResponseWorkspace(process.env))
121
+ expect(claudeCwd.endsWith("/.kanna-dev")).toBe(true)
122
+ } finally {
123
+ if (previousProfile === undefined) {
124
+ delete process.env.KANNA_RUNTIME_PROFILE
125
+ } else {
126
+ process.env.KANNA_RUNTIME_PROFILE = previousProfile
127
+ }
128
+ }
129
+ })
130
+
131
+ test("uses gpt-5.4-mini for Codex title generation fallback", async () => {
132
+ const requests: Array<{ cwd: string; prompt: string; model?: string }> = []
133
+ const adapter = new QuickResponseAdapter({
134
+ codexManager: {
135
+ async generateStructured(args: { cwd: string; prompt: string; model?: string }) {
136
+ requests.push(args)
137
+ return "{\"title\":\"Codex title\"}"
138
+ },
139
+ } as never,
140
+ runClaudeStructured: async () => null,
141
+ })
142
+
143
+ const result = await adapter.generateStructured({
144
+ cwd: "/tmp/project",
145
+ task: "title generation",
146
+ prompt: "Generate a title",
147
+ schema: {
148
+ type: "object",
149
+ properties: {
150
+ title: { type: "string" },
151
+ },
152
+ required: ["title"],
153
+ additionalProperties: false,
154
+ },
155
+ parse: (value) => {
156
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
157
+ return typeof output.title === "string" ? output.title : null
158
+ },
159
+ })
160
+
161
+ expect(result).toBe("Codex title")
162
+ expect(requests).toHaveLength(1)
163
+ expect(requests[0]?.model).toBe("gpt-5.4-mini")
164
+ })
88
165
  })
89
166
 
90
167
  describe("generateTitleForChat", () => {
@@ -110,6 +187,52 @@ describe("generateTitleForChat", () => {
110
187
  })
111
188
  )
112
189
 
113
- expect(title).toBeNull()
190
+ expect(title).toBe("hello")
191
+ })
192
+
193
+ test("falls back to the first 35 characters of the message with ellipsis", async () => {
194
+ const title = await generateTitleForChat(
195
+ "This message is definitely longer than thirty five characters",
196
+ "/tmp/project",
197
+ new QuickResponseAdapter({
198
+ runClaudeStructured: async () => {
199
+ throw new Error("Not authenticated")
200
+ },
201
+ runCodexStructured: async () => null,
202
+ })
203
+ )
204
+
205
+ expect(title).toBe("This message is definitely longer t...")
206
+ })
207
+
208
+ test("returns fallback metadata when providers fail", async () => {
209
+ const result = await generateTitleForChatDetailed(
210
+ "hello there",
211
+ "/tmp/project",
212
+ new QuickResponseAdapter({
213
+ runClaudeStructured: async () => {
214
+ throw new Error("Not authenticated")
215
+ },
216
+ runCodexStructured: async () => {
217
+ throw new Error("Codex unavailable")
218
+ },
219
+ })
220
+ )
221
+
222
+ expect(result).toEqual({
223
+ title: "hello there",
224
+ usedFallback: true,
225
+ failureMessage: "claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable",
226
+ })
227
+ })
228
+ })
229
+
230
+ describe("fallbackTitleFromMessage", () => {
231
+ test("normalizes whitespace", () => {
232
+ expect(fallbackTitleFromMessage(" hello\n world ")).toBe("hello world")
233
+ })
234
+
235
+ test("returns null for blank input", () => {
236
+ expect(fallbackTitleFromMessage(" \n ")).toBeNull()
114
237
  })
115
238
  })