kanna-code 0.32.3 → 0.33.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-BYzAMpzV.css +32 -0
- package/dist/client/assets/index-ab3JkqZ2.js +2608 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +88 -0
- package/src/server/agent.ts +44 -3
- package/src/server/auth.test.ts +119 -2
- package/src/server/auth.ts +46 -11
- package/src/server/cli-runtime.test.ts +4 -0
- package/src/server/cli-runtime.ts +11 -7
- package/src/server/codex-app-server-protocol.ts +12 -0
- package/src/server/codex-app-server.test.ts +32 -0
- package/src/server/codex-app-server.ts +17 -3
- package/src/server/diff-store.ts +0 -9
- package/src/server/event-store.test.ts +100 -2
- package/src/server/event-store.ts +194 -16
- package/src/server/events.ts +8 -7
- package/src/server/provider-catalog.test.ts +6 -0
- package/src/server/provider-catalog.ts +4 -2
- package/src/server/read-models.test.ts +63 -5
- package/src/server/read-models.ts +21 -2
- package/src/server/server.ts +8 -1
- package/src/server/share.ts +13 -42
- package/src/server/ws-router.test.ts +139 -2
- package/src/server/ws-router.ts +16 -1
- package/src/shared/dev-ports.ts +5 -7
- package/src/shared/protocol.ts +1 -0
- package/src/shared/share.ts +15 -0
- package/src/shared/types.test.ts +24 -0
- package/src/shared/types.ts +61 -25
- package/dist/client/assets/index-B3EUEMfY.css +0 -32
- package/dist/client/assets/index-BVeR75Xr.js +0 -2598
|
@@ -26,9 +26,10 @@ describe("read models", () => {
|
|
|
26
26
|
lastTurnOutcome: null,
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
|
|
29
|
+
const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
|
|
30
30
|
expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
|
|
31
31
|
expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
|
|
32
|
+
expect(sidebar.projectGroups[0]?.chats[0]?.canFork).toBe(true)
|
|
32
33
|
expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
|
|
33
34
|
expect(sidebar.projectGroups[0]?.olderChats).toEqual([])
|
|
34
35
|
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
@@ -198,9 +199,7 @@ describe("read models", () => {
|
|
|
198
199
|
createdAt: 3,
|
|
199
200
|
updatedAt: 15,
|
|
200
201
|
})
|
|
201
|
-
state
|
|
202
|
-
|
|
203
|
-
const sidebar = deriveSidebarData(state, new Map())
|
|
202
|
+
const sidebar = deriveSidebarData(state, new Map(), { sidebarProjectOrder: ["project-1"] })
|
|
204
203
|
|
|
205
204
|
expect(sidebar.projectGroups.map((group) => group.groupKey)).toEqual(["project-1", "project-2", "project-3"])
|
|
206
205
|
})
|
|
@@ -242,10 +241,69 @@ describe("read models", () => {
|
|
|
242
241
|
lastTurnOutcome: null,
|
|
243
242
|
})
|
|
244
243
|
|
|
245
|
-
const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
|
|
244
|
+
const sidebar = deriveSidebarData(state, new Map(), { nowMs: 1_000_000 })
|
|
246
245
|
|
|
247
246
|
expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
|
|
248
247
|
expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
|
|
249
248
|
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
250
249
|
})
|
|
250
|
+
|
|
251
|
+
test("disables forking for active and draining chats, but allows pending fork chats", () => {
|
|
252
|
+
const state = createEmptyState()
|
|
253
|
+
state.projectsById.set("project-1", {
|
|
254
|
+
id: "project-1",
|
|
255
|
+
localPath: "/tmp/project",
|
|
256
|
+
title: "Project",
|
|
257
|
+
createdAt: 1,
|
|
258
|
+
updatedAt: 1,
|
|
259
|
+
})
|
|
260
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
261
|
+
state.chatsById.set("chat-active", {
|
|
262
|
+
id: "chat-active",
|
|
263
|
+
projectId: "project-1",
|
|
264
|
+
title: "Active",
|
|
265
|
+
createdAt: 1,
|
|
266
|
+
updatedAt: 1,
|
|
267
|
+
unread: false,
|
|
268
|
+
provider: "claude",
|
|
269
|
+
planMode: false,
|
|
270
|
+
sessionToken: "session-active",
|
|
271
|
+
lastTurnOutcome: null,
|
|
272
|
+
})
|
|
273
|
+
state.chatsById.set("chat-pending", {
|
|
274
|
+
id: "chat-pending",
|
|
275
|
+
projectId: "project-1",
|
|
276
|
+
title: "Pending fork",
|
|
277
|
+
createdAt: 2,
|
|
278
|
+
updatedAt: 2,
|
|
279
|
+
unread: false,
|
|
280
|
+
provider: "claude",
|
|
281
|
+
planMode: false,
|
|
282
|
+
sessionToken: null,
|
|
283
|
+
pendingForkSessionToken: "session-parent",
|
|
284
|
+
lastTurnOutcome: null,
|
|
285
|
+
})
|
|
286
|
+
state.chatsById.set("chat-draining", {
|
|
287
|
+
id: "chat-draining",
|
|
288
|
+
projectId: "project-1",
|
|
289
|
+
title: "Draining",
|
|
290
|
+
createdAt: 3,
|
|
291
|
+
updatedAt: 3,
|
|
292
|
+
unread: false,
|
|
293
|
+
provider: "codex",
|
|
294
|
+
planMode: false,
|
|
295
|
+
sessionToken: "thread-1",
|
|
296
|
+
lastTurnOutcome: null,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const sidebar = deriveSidebarData(
|
|
300
|
+
state,
|
|
301
|
+
new Map([["chat-active", "running"]]),
|
|
302
|
+
{ drainingChatIds: new Set(["chat-draining"]) }
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-active")?.canFork).toBeUndefined()
|
|
306
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-pending")?.canFork).toBe(true)
|
|
307
|
+
expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-draining")?.canFork).toBeUndefined()
|
|
308
|
+
})
|
|
251
309
|
})
|
|
@@ -25,6 +25,18 @@ function getSidebarChatSortTimestamp(chat: ChatRecord) {
|
|
|
25
25
|
return chat.lastMessageAt ?? chat.createdAt
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function canForkChat(
|
|
29
|
+
chat: ChatRecord,
|
|
30
|
+
activeStatuses: Map<string, KannaStatus>,
|
|
31
|
+
drainingChatIds: Set<string>,
|
|
32
|
+
) {
|
|
33
|
+
if (!chat.provider) return false
|
|
34
|
+
if (!chat.sessionToken && !chat.pendingForkSessionToken) return false
|
|
35
|
+
if (activeStatuses.has(chat.id)) return false
|
|
36
|
+
if (drainingChatIds.has(chat.id)) return false
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
function getSidebarChatTimestamp(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">) {
|
|
29
41
|
return chat.lastMessageAt ?? chat._creationTime
|
|
30
42
|
}
|
|
@@ -49,8 +61,14 @@ function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
|
|
|
49
61
|
export function deriveSidebarData(
|
|
50
62
|
state: StoreState,
|
|
51
63
|
activeStatuses: Map<string, KannaStatus>,
|
|
52
|
-
|
|
64
|
+
options?: {
|
|
65
|
+
nowMs?: number
|
|
66
|
+
sidebarProjectOrder?: string[]
|
|
67
|
+
drainingChatIds?: Set<string>
|
|
68
|
+
}
|
|
53
69
|
): SidebarData {
|
|
70
|
+
const nowMs = options?.nowMs ?? Date.now()
|
|
71
|
+
const drainingChatIds = options?.drainingChatIds ?? new Set<string>()
|
|
54
72
|
const chatsByProjectId = new Map<string, ChatRecord[]>()
|
|
55
73
|
for (const chat of state.chatsById.values()) {
|
|
56
74
|
if (chat.deletedAt) continue
|
|
@@ -67,7 +85,7 @@ export function deriveSidebarData(
|
|
|
67
85
|
const unorderedProjects = allProjects
|
|
68
86
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
69
87
|
const projectById = new Map(unorderedProjects.map((project) => [project.id, project]))
|
|
70
|
-
const orderedProjects =
|
|
88
|
+
const orderedProjects = (options?.sidebarProjectOrder ?? [])
|
|
71
89
|
.map((projectId) => projectById.get(projectId))
|
|
72
90
|
.filter((project): project is NonNullable<typeof project> => Boolean(project))
|
|
73
91
|
const orderedProjectIds = new Set(orderedProjects.map((project) => project.id))
|
|
@@ -90,6 +108,7 @@ export function deriveSidebarData(
|
|
|
90
108
|
provider: chat.provider,
|
|
91
109
|
lastMessageAt: chat.lastMessageAt,
|
|
92
110
|
hasAutomation: false,
|
|
111
|
+
canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
|
|
93
112
|
}))
|
|
94
113
|
const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
|
|
95
114
|
|
package/src/server/server.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface StartKannaServerOptions {
|
|
|
60
60
|
host?: string
|
|
61
61
|
password?: string | null
|
|
62
62
|
strictPort?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* When true, the auth layer trusts X-Forwarded-Proto for CSRF origin
|
|
65
|
+
* checks, redirect URLs, and the Secure cookie flag. The hostname still
|
|
66
|
+
* comes from the request URL / Host header. Only enable when the server is
|
|
67
|
+
* reachable solely through a trusted reverse proxy such as cloudflared.
|
|
68
|
+
*/
|
|
69
|
+
trustProxy?: boolean
|
|
63
70
|
onMigrationProgress?: (message: string) => void
|
|
64
71
|
update?: {
|
|
65
72
|
version: string
|
|
@@ -72,7 +79,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
72
79
|
const port = options.port ?? 3210
|
|
73
80
|
const hostname = options.host ?? "127.0.0.1"
|
|
74
81
|
const strictPort = options.strictPort ?? false
|
|
75
|
-
const auth = options.password ? createAuthManager(options.password) : null
|
|
82
|
+
const auth = options.password ? createAuthManager(options.password, { trustProxy: options.trustProxy ?? false }) : null
|
|
76
83
|
const store = new EventStore()
|
|
77
84
|
const diffStore = new DiffStore(store.dataDir)
|
|
78
85
|
const machineDisplayName = getMachineDisplayName()
|
package/src/server/share.ts
CHANGED
|
@@ -63,42 +63,10 @@ function createNamedTunnel(token: string, localUrl: string) {
|
|
|
63
63
|
return tunnel
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
async function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const cleanup = () => {
|
|
71
|
-
tunnel.off("url", handleUrl)
|
|
72
|
-
tunnel.off("error", handleError)
|
|
73
|
-
tunnel.off("exit", handleExit)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const settle = (callback: () => void) => {
|
|
77
|
-
if (settled) return
|
|
78
|
-
settled = true
|
|
79
|
-
cleanup()
|
|
80
|
-
callback()
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const handleUrl = (url: string) => {
|
|
84
|
-
settle(() => resolve(normalizePublicUrl(url)))
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const handleError = (error: Error) => {
|
|
88
|
-
settle(() => reject(error))
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
92
|
-
settle(() => reject(new Error(`Cloudflare tunnel exited before a public URL was ready (code: ${String(code)}, signal: ${String(signal)})`)))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
tunnel.once("url", handleUrl)
|
|
96
|
-
tunnel.once("error", handleError)
|
|
97
|
-
tunnel.once("exit", handleExit)
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
|
|
66
|
+
async function awaitTunnelReady(
|
|
67
|
+
tunnel: ShareTunnelProcess,
|
|
68
|
+
{ expectUrl }: { expectUrl: boolean },
|
|
69
|
+
) {
|
|
102
70
|
return await new Promise<string | null>((resolve, reject) => {
|
|
103
71
|
let settled = false
|
|
104
72
|
|
|
@@ -121,6 +89,7 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
|
|
|
121
89
|
}
|
|
122
90
|
|
|
123
91
|
const handleConnected = () => {
|
|
92
|
+
if (expectUrl) return
|
|
124
93
|
settle(() => resolve(null))
|
|
125
94
|
}
|
|
126
95
|
|
|
@@ -129,11 +98,14 @@ async function awaitNamedTunnelReady(tunnel: ShareTunnelProcess) {
|
|
|
129
98
|
}
|
|
130
99
|
|
|
131
100
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
132
|
-
|
|
101
|
+
const readyState = expectUrl ? "a public URL was ready" : "it was ready"
|
|
102
|
+
settle(() => reject(new Error(`Cloudflare tunnel exited before ${readyState} (code: ${String(code)}, signal: ${String(signal)})`)))
|
|
133
103
|
}
|
|
134
104
|
|
|
135
105
|
tunnel.once("url", handleUrl)
|
|
136
|
-
|
|
106
|
+
if (!expectUrl) {
|
|
107
|
+
tunnel.once("connected", handleConnected)
|
|
108
|
+
}
|
|
137
109
|
tunnel.once("error", handleError)
|
|
138
110
|
tunnel.once("exit", handleExit)
|
|
139
111
|
})
|
|
@@ -145,12 +117,11 @@ export async function startShareTunnel(
|
|
|
145
117
|
deps: ShareTunnelDeps = {},
|
|
146
118
|
): Promise<StartedShareTunnel> {
|
|
147
119
|
await ensureCloudflaredInstalled(deps)
|
|
148
|
-
const
|
|
120
|
+
const namedTunnel = isTokenShareMode(shareMode)
|
|
121
|
+
const tunnel = namedTunnel
|
|
149
122
|
? (deps.createNamedTunnel ?? createNamedTunnel)(shareMode.token, localUrl)
|
|
150
123
|
: (deps.createQuickTunnel ?? ((url) => Tunnel.quick(url)))(localUrl)
|
|
151
|
-
const publicUrl =
|
|
152
|
-
? await awaitNamedTunnelReady(tunnel)
|
|
153
|
-
: await awaitQuickTunnelUrl(tunnel)
|
|
124
|
+
const publicUrl = await awaitTunnelReady(tunnel, { expectUrl: !namedTunnel })
|
|
154
125
|
|
|
155
126
|
return {
|
|
156
127
|
publicUrl,
|
|
@@ -17,6 +17,7 @@ function withSidebarGroupDefaults(group: {
|
|
|
17
17
|
localPath: string
|
|
18
18
|
provider: "claude" | "codex" | null
|
|
19
19
|
lastMessageAt?: number
|
|
20
|
+
canFork?: boolean
|
|
20
21
|
hasAutomation: boolean
|
|
21
22
|
}>
|
|
22
23
|
}) {
|
|
@@ -739,7 +740,10 @@ describe("ws-router", () => {
|
|
|
739
740
|
|
|
740
741
|
const router = createWsRouter({
|
|
741
742
|
store: store as never,
|
|
742
|
-
agent: {
|
|
743
|
+
agent: {
|
|
744
|
+
getActiveStatuses: () => new Map(),
|
|
745
|
+
getDrainingChatIds: () => new Set(),
|
|
746
|
+
} as never,
|
|
743
747
|
terminals: {
|
|
744
748
|
getSnapshot: () => null,
|
|
745
749
|
onEvent: () => () => {},
|
|
@@ -863,12 +867,16 @@ describe("ws-router", () => {
|
|
|
863
867
|
})
|
|
864
868
|
|
|
865
869
|
const setSidebarProjectOrderCalls: string[][] = []
|
|
870
|
+
let sidebarProjectOrder: string[] = []
|
|
866
871
|
const router = createWsRouter({
|
|
867
872
|
store: {
|
|
868
873
|
state,
|
|
874
|
+
getSidebarProjectOrder() {
|
|
875
|
+
return [...sidebarProjectOrder]
|
|
876
|
+
},
|
|
869
877
|
async setSidebarProjectOrder(projectIds: string[]) {
|
|
870
878
|
setSidebarProjectOrderCalls.push(projectIds)
|
|
871
|
-
|
|
879
|
+
sidebarProjectOrder = [...projectIds]
|
|
872
880
|
},
|
|
873
881
|
} as never,
|
|
874
882
|
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
@@ -938,6 +946,135 @@ describe("ws-router", () => {
|
|
|
938
946
|
})
|
|
939
947
|
})
|
|
940
948
|
|
|
949
|
+
test("forks a chat through the agent and rebroadcasts the sidebar snapshot", async () => {
|
|
950
|
+
const state = createEmptyState()
|
|
951
|
+
state.projectsById.set("project-1", {
|
|
952
|
+
id: "project-1",
|
|
953
|
+
localPath: "/tmp/project",
|
|
954
|
+
title: "Project",
|
|
955
|
+
createdAt: 1,
|
|
956
|
+
updatedAt: 1,
|
|
957
|
+
})
|
|
958
|
+
state.chatsById.set("chat-1", {
|
|
959
|
+
id: "chat-1",
|
|
960
|
+
projectId: "project-1",
|
|
961
|
+
title: "Chat",
|
|
962
|
+
createdAt: 1,
|
|
963
|
+
updatedAt: 1,
|
|
964
|
+
unread: false,
|
|
965
|
+
provider: "claude",
|
|
966
|
+
planMode: false,
|
|
967
|
+
sessionToken: "session-1",
|
|
968
|
+
pendingForkSessionToken: null,
|
|
969
|
+
lastTurnOutcome: null,
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
const forkChatCalls: string[] = []
|
|
973
|
+
const router = createWsRouter({
|
|
974
|
+
store: { state } as never,
|
|
975
|
+
agent: {
|
|
976
|
+
getActiveStatuses: () => new Map(),
|
|
977
|
+
getDrainingChatIds: () => new Set(),
|
|
978
|
+
forkChat: async (chatId: string) => {
|
|
979
|
+
forkChatCalls.push(chatId)
|
|
980
|
+
state.chatsById.set("chat-fork-1", {
|
|
981
|
+
id: "chat-fork-1",
|
|
982
|
+
projectId: "project-1",
|
|
983
|
+
title: "Fork: Chat",
|
|
984
|
+
createdAt: 2,
|
|
985
|
+
updatedAt: 2,
|
|
986
|
+
unread: false,
|
|
987
|
+
provider: "claude",
|
|
988
|
+
planMode: false,
|
|
989
|
+
sessionToken: null,
|
|
990
|
+
pendingForkSessionToken: "session-1",
|
|
991
|
+
lastTurnOutcome: null,
|
|
992
|
+
})
|
|
993
|
+
return { chatId: "chat-fork-1" }
|
|
994
|
+
},
|
|
995
|
+
} as never,
|
|
996
|
+
terminals: {
|
|
997
|
+
getSnapshot: () => null,
|
|
998
|
+
onEvent: () => () => {},
|
|
999
|
+
} as never,
|
|
1000
|
+
keybindings: {
|
|
1001
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
1002
|
+
onChange: () => () => {},
|
|
1003
|
+
} as never,
|
|
1004
|
+
refreshDiscovery: async () => [],
|
|
1005
|
+
getDiscoveredProjects: () => [],
|
|
1006
|
+
machineDisplayName: "Local Machine",
|
|
1007
|
+
updateManager: null,
|
|
1008
|
+
})
|
|
1009
|
+
const ws = new FakeWebSocket()
|
|
1010
|
+
router.handleOpen(ws as never)
|
|
1011
|
+
|
|
1012
|
+
await router.handleMessage(
|
|
1013
|
+
ws as never,
|
|
1014
|
+
JSON.stringify({
|
|
1015
|
+
v: 1,
|
|
1016
|
+
type: "subscribe",
|
|
1017
|
+
id: "sidebar-sub-1",
|
|
1018
|
+
topic: { type: "sidebar" },
|
|
1019
|
+
})
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
await router.handleMessage(
|
|
1023
|
+
ws as never,
|
|
1024
|
+
JSON.stringify({
|
|
1025
|
+
v: 1,
|
|
1026
|
+
type: "command",
|
|
1027
|
+
id: "fork-1",
|
|
1028
|
+
command: { type: "chat.fork", chatId: "chat-1" },
|
|
1029
|
+
})
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
expect(forkChatCalls).toEqual(["chat-1"])
|
|
1033
|
+
expect(ws.sent.at(-2)).toEqual({
|
|
1034
|
+
v: PROTOCOL_VERSION,
|
|
1035
|
+
type: "ack",
|
|
1036
|
+
id: "fork-1",
|
|
1037
|
+
result: { chatId: "chat-fork-1" },
|
|
1038
|
+
})
|
|
1039
|
+
expect(ws.sent.at(-1)).toEqual({
|
|
1040
|
+
v: PROTOCOL_VERSION,
|
|
1041
|
+
type: "snapshot",
|
|
1042
|
+
id: "sidebar-sub-1",
|
|
1043
|
+
snapshot: {
|
|
1044
|
+
type: "sidebar",
|
|
1045
|
+
data: {
|
|
1046
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
1047
|
+
groupKey: "project-1",
|
|
1048
|
+
localPath: "/tmp/project",
|
|
1049
|
+
chats: [{
|
|
1050
|
+
_id: "chat-fork-1",
|
|
1051
|
+
_creationTime: 2,
|
|
1052
|
+
chatId: "chat-fork-1",
|
|
1053
|
+
title: "Fork: Chat",
|
|
1054
|
+
status: "idle",
|
|
1055
|
+
unread: false,
|
|
1056
|
+
localPath: "/tmp/project",
|
|
1057
|
+
provider: "claude",
|
|
1058
|
+
canFork: true,
|
|
1059
|
+
hasAutomation: false,
|
|
1060
|
+
}, {
|
|
1061
|
+
_id: "chat-1",
|
|
1062
|
+
_creationTime: 1,
|
|
1063
|
+
chatId: "chat-1",
|
|
1064
|
+
title: "Chat",
|
|
1065
|
+
status: "idle",
|
|
1066
|
+
unread: false,
|
|
1067
|
+
localPath: "/tmp/project",
|
|
1068
|
+
provider: "claude",
|
|
1069
|
+
canFork: true,
|
|
1070
|
+
hasAutomation: false,
|
|
1071
|
+
}],
|
|
1072
|
+
})],
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
})
|
|
1076
|
+
})
|
|
1077
|
+
|
|
941
1078
|
test("prunes stale empty chats during explicit maintenance runs", async () => {
|
|
942
1079
|
const state = createEmptyState()
|
|
943
1080
|
state.projectsById.set("project-1", {
|
package/src/server/ws-router.ts
CHANGED
|
@@ -126,6 +126,12 @@ interface SnapshotComputationCache {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function getSidebarProjectOrder(store: EventStore) {
|
|
130
|
+
return typeof store.getSidebarProjectOrder === "function"
|
|
131
|
+
? store.getSidebarProjectOrder()
|
|
132
|
+
: []
|
|
133
|
+
}
|
|
134
|
+
|
|
129
135
|
function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
|
|
130
136
|
const payload = JSON.stringify(message)
|
|
131
137
|
ws.send(payload)
|
|
@@ -300,7 +306,10 @@ export function createWsRouter({
|
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
const startedAt = performance.now()
|
|
303
|
-
const data = deriveSidebarData(store.state, agent.getActiveStatuses()
|
|
309
|
+
const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
|
|
310
|
+
sidebarProjectOrder: getSidebarProjectOrder(store),
|
|
311
|
+
drainingChatIds: agent.getDrainingChatIds(),
|
|
312
|
+
})
|
|
304
313
|
if (isSendToStartingProfilingEnabled()) {
|
|
305
314
|
const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
|
|
306
315
|
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
@@ -805,6 +814,12 @@ export function createWsRouter({
|
|
|
805
814
|
await broadcastChatAndSidebar(chat.id)
|
|
806
815
|
return
|
|
807
816
|
}
|
|
817
|
+
case "chat.fork": {
|
|
818
|
+
const result = await agent.forkChat(command.chatId)
|
|
819
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
820
|
+
await broadcastFilteredSnapshots({ includeSidebar: true })
|
|
821
|
+
return
|
|
822
|
+
}
|
|
808
823
|
case "chat.rename": {
|
|
809
824
|
await store.renameChat(command.chatId, command.title)
|
|
810
825
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
package/src/shared/dev-ports.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ShareMode } from "./share"
|
|
2
|
-
import { isShareEnabled } from "./share"
|
|
2
|
+
import { assertNoHostOverride, getShareCliFlag, isShareEnabled } from "./share"
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_DEV_CLIENT_PORT = 5174
|
|
5
5
|
|
|
@@ -89,14 +89,12 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
|
|
|
89
89
|
for (let index = 0; index < args.length; index += 1) {
|
|
90
90
|
const arg = args[index]
|
|
91
91
|
if (arg === "--share") {
|
|
92
|
-
|
|
93
|
-
if (sawRemote) throw new Error("--share cannot be used with --remote")
|
|
92
|
+
assertNoHostOverride("--share", sawHost, sawRemote)
|
|
94
93
|
share = "quick"
|
|
95
94
|
continue
|
|
96
95
|
}
|
|
97
96
|
if (arg === "--cloudflared") {
|
|
98
|
-
|
|
99
|
-
if (sawRemote) throw new Error("--cloudflared cannot be used with --remote")
|
|
97
|
+
assertNoHostOverride("--cloudflared", sawHost, sawRemote)
|
|
100
98
|
const next = args[index + 1]
|
|
101
99
|
if (!next || next.startsWith("-")) throw new Error("Missing value for --cloudflared")
|
|
102
100
|
share = { kind: "token", token: next }
|
|
@@ -105,7 +103,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
|
|
|
105
103
|
}
|
|
106
104
|
if (arg === "--remote") {
|
|
107
105
|
if (isShareEnabled(share)) {
|
|
108
|
-
throw new Error(
|
|
106
|
+
throw new Error(`${getShareCliFlag(share)} cannot be used with --remote`)
|
|
109
107
|
}
|
|
110
108
|
sawRemote = true
|
|
111
109
|
backendTargetHost = "127.0.0.1"
|
|
@@ -117,7 +115,7 @@ export function parseDevArgs(args: string[], localHostname: string): DevArgResol
|
|
|
117
115
|
const next = args[index + 1]
|
|
118
116
|
if (!next || next.startsWith("-")) continue
|
|
119
117
|
if (isShareEnabled(share)) {
|
|
120
|
-
throw new Error(
|
|
118
|
+
throw new Error(`${getShareCliFlag(share)} cannot be used with --host`)
|
|
121
119
|
}
|
|
122
120
|
sawHost = true
|
|
123
121
|
hosts.add(next)
|
package/src/shared/protocol.ts
CHANGED
|
@@ -82,6 +82,7 @@ export type ClientCommand =
|
|
|
82
82
|
editor?: EditorOpenSettings
|
|
83
83
|
}
|
|
84
84
|
| { type: "chat.create"; projectId: string }
|
|
85
|
+
| { type: "chat.fork"; chatId: string }
|
|
85
86
|
| { type: "chat.rename"; chatId: string; title: string }
|
|
86
87
|
| { type: "chat.delete"; chatId: string }
|
|
87
88
|
| { type: "chat.setDraftProtection"; chatIds: string[] }
|
package/src/shared/share.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type ShareMode = false | "quick" | {
|
|
|
3
3
|
token: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export type ShareCliFlag = "--share" | "--cloudflared"
|
|
7
|
+
|
|
6
8
|
export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, false> {
|
|
7
9
|
return share !== false
|
|
8
10
|
}
|
|
@@ -10,3 +12,16 @@ export function isShareEnabled(share: ShareMode): share is Exclude<ShareMode, fa
|
|
|
10
12
|
export function isTokenShareMode(share: ShareMode): share is { kind: "token"; token: string } {
|
|
11
13
|
return typeof share === "object" && share !== null && share.kind === "token"
|
|
12
14
|
}
|
|
15
|
+
|
|
16
|
+
export function getShareCliFlag(share: Exclude<ShareMode, false>): ShareCliFlag {
|
|
17
|
+
return isTokenShareMode(share) ? "--cloudflared" : "--share"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertNoHostOverride(shareFlag: ShareCliFlag, sawHost: boolean, sawRemote: boolean) {
|
|
21
|
+
if (sawHost) {
|
|
22
|
+
throw new Error(`${shareFlag} cannot be used with --host`)
|
|
23
|
+
}
|
|
24
|
+
if (sawRemote) {
|
|
25
|
+
throw new Error(`${shareFlag} cannot be used with --remote`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
normalizeClaudeModelId,
|
|
4
|
+
normalizeCodexModelId,
|
|
5
|
+
supportsClaudeMaxReasoningEffort,
|
|
6
|
+
} from "./types"
|
|
7
|
+
|
|
8
|
+
describe("shared model normalization", () => {
|
|
9
|
+
test("normalizes Claude aliases via the provider catalog", () => {
|
|
10
|
+
expect(normalizeClaudeModelId("opus")).toBe("claude-opus-4-7")
|
|
11
|
+
expect(normalizeClaudeModelId("sonnet")).toBe("claude-sonnet-4-6")
|
|
12
|
+
expect(normalizeClaudeModelId("haiku")).toBe("claude-haiku-4-5-20251001")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("normalizes legacy Codex aliases via the provider catalog", () => {
|
|
16
|
+
expect(normalizeCodexModelId("gpt-5-codex")).toBe("gpt-5.3-codex")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("uses declarative metadata for Claude max-effort support", () => {
|
|
20
|
+
expect(supportsClaudeMaxReasoningEffort("claude-opus-4-7")).toBe(true)
|
|
21
|
+
expect(supportsClaudeMaxReasoningEffort("opus")).toBe(true)
|
|
22
|
+
expect(supportsClaudeMaxReasoningEffort("claude-sonnet-4-6")).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|