kanna-code 0.17.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.
- package/dist/client/assets/index-C952_xJD.css +32 -0
- package/dist/client/assets/index-lgfU0zw2.js +606 -0
- package/dist/client/chat-sounds/Blow.mp3 +0 -0
- package/dist/client/chat-sounds/Bottle.mp3 +0 -0
- package/dist/client/chat-sounds/Frog.mp3 +0 -0
- package/dist/client/chat-sounds/Funk.mp3 +0 -0
- package/dist/client/chat-sounds/Glass.mp3 +0 -0
- package/dist/client/chat-sounds/Ping.mp3 +0 -0
- package/dist/client/chat-sounds/Pop.mp3 +0 -0
- package/dist/client/chat-sounds/Purr.mp3 +0 -0
- package/dist/client/chat-sounds/Tink.mp3 +0 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/agent.test.ts +153 -4
- package/src/server/agent.ts +76 -15
- package/src/server/event-store.test.ts +65 -0
- package/src/server/event-store.ts +27 -1
- package/src/server/events.ts +8 -0
- package/src/server/generate-title.ts +42 -2
- package/src/server/paths.ts +4 -0
- package/src/server/quick-response.test.ts +126 -3
- package/src/server/quick-response.ts +107 -14
- package/src/server/read-models.test.ts +4 -0
- package/src/server/read-models.ts +1 -0
- package/src/server/server.ts +178 -1
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/uploads.test.ts +282 -0
- package/src/server/uploads.ts +127 -0
- package/src/server/ws-router.test.ts +181 -0
- package/src/server/ws-router.ts +18 -0
- package/src/shared/protocol.ts +3 -0
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +76 -0
- package/src/shared/types.ts +34 -1
- package/dist/client/assets/index-CtKZIZdD.js +0 -533
- package/dist/client/assets/index-Cwex1U8S.css +0 -32
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
4
|
+
"version": "0.19.0",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@xterm/xterm": "^6.0.0",
|
|
55
55
|
"cloudflared": "^0.7.1",
|
|
56
56
|
"default-shell": "^2.2.0",
|
|
57
|
+
"file-type": "^22.0.0",
|
|
57
58
|
"qrcode": "^1.5.4",
|
|
58
59
|
"react-resizable-panels": "^4.7.3"
|
|
59
60
|
},
|
package/src/server/agent.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
|
|
2
|
+
import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
|
|
3
3
|
import type { HarnessTurn } from "./harness-types"
|
|
4
|
-
import type { TranscriptEntry } from "../shared/types"
|
|
4
|
+
import type { ChatAttachment, TranscriptEntry } from "../shared/types"
|
|
5
5
|
|
|
6
6
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
|
|
7
7
|
return {
|
|
@@ -63,8 +63,74 @@ describe("normalizeClaudeStreamMessage", () => {
|
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
describe("attachment prompt helpers", () => {
|
|
67
|
+
test("appends a structured attachment hint block for all attachment kinds", () => {
|
|
68
|
+
const attachments: ChatAttachment[] = [
|
|
69
|
+
{
|
|
70
|
+
id: "image-1",
|
|
71
|
+
kind: "image",
|
|
72
|
+
displayName: "shot.png",
|
|
73
|
+
absolutePath: "/tmp/project/.kanna/uploads/shot.png",
|
|
74
|
+
relativePath: "./.kanna/uploads/shot.png",
|
|
75
|
+
contentUrl: "/api/projects/project-1/uploads/shot.png/content",
|
|
76
|
+
mimeType: "image/png",
|
|
77
|
+
size: 512,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "file-1",
|
|
81
|
+
kind: "file",
|
|
82
|
+
displayName: "spec.pdf",
|
|
83
|
+
absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
|
|
84
|
+
relativePath: "./.kanna/uploads/spec.pdf",
|
|
85
|
+
contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
|
|
86
|
+
mimeType: "application/pdf",
|
|
87
|
+
size: 1234,
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const prompt = buildPromptText("Review these", attachments)
|
|
92
|
+
expect(prompt).toContain("<kanna-attachments>")
|
|
93
|
+
expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
|
|
94
|
+
expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("supports attachment-only prompts", () => {
|
|
98
|
+
const attachments: ChatAttachment[] = [{
|
|
99
|
+
id: "file-1",
|
|
100
|
+
kind: "file",
|
|
101
|
+
displayName: "todo.txt",
|
|
102
|
+
absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
|
|
103
|
+
relativePath: "./.kanna/uploads/todo.txt",
|
|
104
|
+
contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
|
|
105
|
+
mimeType: "text/plain",
|
|
106
|
+
size: 32,
|
|
107
|
+
}]
|
|
108
|
+
|
|
109
|
+
expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("escapes xml attribute values for attachment hint markup", () => {
|
|
113
|
+
const hint = buildAttachmentHintText([{
|
|
114
|
+
id: "file-1",
|
|
115
|
+
kind: "file",
|
|
116
|
+
displayName: "\"report\" <draft>.txt",
|
|
117
|
+
absolutePath: "/tmp/project/.kanna/uploads/report.txt",
|
|
118
|
+
relativePath: "./.kanna/uploads/report.txt",
|
|
119
|
+
contentUrl: "/api/projects/project-1/uploads/report.txt/content",
|
|
120
|
+
mimeType: "text/plain",
|
|
121
|
+
size: 64,
|
|
122
|
+
}])
|
|
123
|
+
|
|
124
|
+
expect(hint).toContain(""report" <draft>.txt")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
66
128
|
describe("AgentCoordinator codex integration", () => {
|
|
67
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
|
+
})
|
|
68
134
|
const fakeCodexManager = {
|
|
69
135
|
async startSession() {},
|
|
70
136
|
async startTurn(): Promise<HarnessTurn> {
|
|
@@ -107,7 +173,14 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
107
173
|
store: store as never,
|
|
108
174
|
onStateChange: () => {},
|
|
109
175
|
codexManager: fakeCodexManager as never,
|
|
110
|
-
generateTitle: async () =>
|
|
176
|
+
generateTitle: async () => {
|
|
177
|
+
await titleGate
|
|
178
|
+
return {
|
|
179
|
+
title: "Generated title",
|
|
180
|
+
usedFallback: false,
|
|
181
|
+
failureMessage: null,
|
|
182
|
+
}
|
|
183
|
+
},
|
|
111
184
|
})
|
|
112
185
|
|
|
113
186
|
await coordinator.send({
|
|
@@ -118,6 +191,8 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
118
191
|
model: "gpt-5.4",
|
|
119
192
|
})
|
|
120
193
|
|
|
194
|
+
expect(store.chat.title).toBe("first message")
|
|
195
|
+
releaseTitle()
|
|
121
196
|
await waitFor(() => store.chat.title === "Generated title")
|
|
122
197
|
expect(store.messages[0]?.kind).toBe("user_prompt")
|
|
123
198
|
})
|
|
@@ -171,7 +246,11 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
171
246
|
codexManager: fakeCodexManager as never,
|
|
172
247
|
generateTitle: async () => {
|
|
173
248
|
await titleGate
|
|
174
|
-
return
|
|
249
|
+
return {
|
|
250
|
+
title: "Generated title",
|
|
251
|
+
usedFallback: false,
|
|
252
|
+
failureMessage: null,
|
|
253
|
+
}
|
|
175
254
|
},
|
|
176
255
|
})
|
|
177
256
|
|
|
@@ -190,6 +269,76 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
190
269
|
expect(store.chat.title).toBe("Manual title")
|
|
191
270
|
})
|
|
192
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
|
+
|
|
193
342
|
test("binds codex provider and reuses the session token on later turns", async () => {
|
|
194
343
|
const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
|
|
195
344
|
const fakeCodexManager = {
|
package/src/server/agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
2
|
import type {
|
|
3
3
|
AgentProvider,
|
|
4
|
+
ChatAttachment,
|
|
4
5
|
NormalizedToolCall,
|
|
5
6
|
PendingToolSnapshot,
|
|
6
7
|
KannaStatus,
|
|
@@ -10,7 +11,7 @@ import { normalizeToolCall } from "../shared/tools"
|
|
|
10
11
|
import type { ClientCommand } from "../shared/protocol"
|
|
11
12
|
import { EventStore } from "./event-store"
|
|
12
13
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
13
|
-
import {
|
|
14
|
+
import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
|
|
14
15
|
import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
|
|
15
16
|
import {
|
|
16
17
|
codexServiceTierFromModelOptions,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
normalizeServerModel,
|
|
21
22
|
} from "./provider-catalog"
|
|
22
23
|
import { resolveClaudeApiModelId } from "../shared/types"
|
|
24
|
+
import { fallbackTitleFromMessage } from "./generate-title"
|
|
23
25
|
|
|
24
26
|
const CLAUDE_TOOLSET = [
|
|
25
27
|
"Skill",
|
|
@@ -66,7 +68,7 @@ interface AgentCoordinatorArgs {
|
|
|
66
68
|
store: EventStore
|
|
67
69
|
onStateChange: () => void
|
|
68
70
|
codexManager?: CodexAppServerManager
|
|
69
|
-
generateTitle?: (messageContent: string, cwd: string) => Promise<
|
|
71
|
+
generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
@@ -89,6 +91,41 @@ function stringFromUnknown(value: unknown) {
|
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function escapeXmlAttribute(value: string) {
|
|
95
|
+
return value
|
|
96
|
+
.replaceAll("&", "&")
|
|
97
|
+
.replaceAll("\"", """)
|
|
98
|
+
.replaceAll("<", "<")
|
|
99
|
+
.replaceAll(">", ">")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildAttachmentHintText(attachments: ChatAttachment[]) {
|
|
103
|
+
if (attachments.length === 0) return ""
|
|
104
|
+
|
|
105
|
+
const lines = attachments.map((attachment) => (
|
|
106
|
+
`<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
|
|
107
|
+
))
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
"<kanna-attachments>",
|
|
111
|
+
...lines,
|
|
112
|
+
"</kanna-attachments>",
|
|
113
|
+
].join("\n")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildPromptText(content: string, attachments: ChatAttachment[]) {
|
|
117
|
+
const attachmentHint = buildAttachmentHintText(attachments)
|
|
118
|
+
if (!attachmentHint) {
|
|
119
|
+
return content.trim()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const trimmed = content.trim()
|
|
123
|
+
return [
|
|
124
|
+
trimmed || "Please inspect the attached files.",
|
|
125
|
+
attachmentHint,
|
|
126
|
+
].join("\n\n").trim()
|
|
127
|
+
}
|
|
128
|
+
|
|
92
129
|
function discardedToolResult(
|
|
93
130
|
tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
|
|
94
131
|
) {
|
|
@@ -334,14 +371,19 @@ export class AgentCoordinator {
|
|
|
334
371
|
private readonly store: EventStore
|
|
335
372
|
private readonly onStateChange: () => void
|
|
336
373
|
private readonly codexManager: CodexAppServerManager
|
|
337
|
-
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<
|
|
374
|
+
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
375
|
+
private reportBackgroundError: ((message: string) => void) | null = null
|
|
338
376
|
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
339
377
|
|
|
340
378
|
constructor(args: AgentCoordinatorArgs) {
|
|
341
379
|
this.store = args.store
|
|
342
380
|
this.onStateChange = args.onStateChange
|
|
343
381
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
344
|
-
this.generateTitle = args.generateTitle ??
|
|
382
|
+
this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
setBackgroundErrorReporter(report: ((message: string) => void) | null) {
|
|
386
|
+
this.reportBackgroundError = report
|
|
345
387
|
}
|
|
346
388
|
|
|
347
389
|
getActiveStatuses() {
|
|
@@ -389,6 +431,7 @@ export class AgentCoordinator {
|
|
|
389
431
|
chatId: string
|
|
390
432
|
provider: AgentProvider
|
|
391
433
|
content: string
|
|
434
|
+
attachments: ChatAttachment[]
|
|
392
435
|
model: string
|
|
393
436
|
effort?: string
|
|
394
437
|
serviceTier?: "fast"
|
|
@@ -407,9 +450,17 @@ export class AgentCoordinator {
|
|
|
407
450
|
|
|
408
451
|
const existingMessages = this.store.getMessages(args.chatId)
|
|
409
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
|
+
}
|
|
410
458
|
|
|
411
459
|
if (args.appendUserPrompt) {
|
|
412
|
-
await this.store.appendMessage(
|
|
460
|
+
await this.store.appendMessage(
|
|
461
|
+
args.chatId,
|
|
462
|
+
timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
|
|
463
|
+
)
|
|
413
464
|
}
|
|
414
465
|
await this.store.recordTurnStarted(args.chatId)
|
|
415
466
|
|
|
@@ -419,7 +470,7 @@ export class AgentCoordinator {
|
|
|
419
470
|
}
|
|
420
471
|
|
|
421
472
|
if (shouldGenerateTitle) {
|
|
422
|
-
void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
|
|
473
|
+
void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
|
|
423
474
|
}
|
|
424
475
|
|
|
425
476
|
const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
|
|
@@ -443,7 +494,7 @@ export class AgentCoordinator {
|
|
|
443
494
|
let turn: HarnessTurn
|
|
444
495
|
if (args.provider === "claude") {
|
|
445
496
|
turn = await startClaudeTurn({
|
|
446
|
-
content: args.content,
|
|
497
|
+
content: buildPromptText(args.content, args.attachments),
|
|
447
498
|
localPath: project.localPath,
|
|
448
499
|
model: args.model,
|
|
449
500
|
effort: args.effort,
|
|
@@ -461,7 +512,7 @@ export class AgentCoordinator {
|
|
|
461
512
|
})
|
|
462
513
|
turn = await this.codexManager.startTurn({
|
|
463
514
|
chatId: args.chatId,
|
|
464
|
-
content: args.content,
|
|
515
|
+
content: buildPromptText(args.content, args.attachments),
|
|
465
516
|
model: args.model,
|
|
466
517
|
effort: args.effort as any,
|
|
467
518
|
serviceTier: args.serviceTier,
|
|
@@ -519,6 +570,7 @@ export class AgentCoordinator {
|
|
|
519
570
|
chatId,
|
|
520
571
|
provider,
|
|
521
572
|
content: command.content,
|
|
573
|
+
attachments: command.attachments ?? [],
|
|
522
574
|
model: settings.model,
|
|
523
575
|
effort: settings.effort,
|
|
524
576
|
serviceTier: settings.serviceTier,
|
|
@@ -529,18 +581,26 @@ export class AgentCoordinator {
|
|
|
529
581
|
return { chatId }
|
|
530
582
|
}
|
|
531
583
|
|
|
532
|
-
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
|
|
584
|
+
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
|
|
533
585
|
try {
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
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
|
|
536
593
|
|
|
537
594
|
const chat = this.store.requireChat(chatId)
|
|
538
|
-
if (chat.title !==
|
|
595
|
+
if (chat.title !== expectedCurrentTitle) return
|
|
539
596
|
|
|
540
|
-
await this.store.renameChat(chatId, title)
|
|
597
|
+
await this.store.renameChat(chatId, result.title)
|
|
541
598
|
this.onStateChange()
|
|
542
|
-
} catch {
|
|
543
|
-
|
|
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
|
+
)
|
|
544
604
|
}
|
|
545
605
|
}
|
|
546
606
|
|
|
@@ -600,6 +660,7 @@ export class AgentCoordinator {
|
|
|
600
660
|
chatId: active.chatId,
|
|
601
661
|
provider: active.provider,
|
|
602
662
|
content: active.postToolFollowUp.content,
|
|
663
|
+
attachments: [],
|
|
603
664
|
model: active.model,
|
|
604
665
|
effort: active.effort,
|
|
605
666
|
serviceTier: active.serviceTier,
|
|
@@ -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, {
|
|
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`
|
package/src/server/events.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/server/paths.ts
CHANGED
|
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
|
|
|
25
25
|
throw new Error("Project path must be a directory")
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
export function getProjectUploadDir(localPath: string) {
|
|
30
|
+
return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
|
|
31
|
+
}
|