kanna-code 0.32.4 → 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.
@@ -34,6 +34,8 @@ import {
34
34
  type ThreadItem,
35
35
  type ThreadResumeParams,
36
36
  type ThreadResumeResponse,
37
+ type ThreadForkParams,
38
+ type ThreadForkResponse,
37
39
  type ThreadStartParams,
38
40
  type ThreadStartResponse,
39
41
  type ThreadTokenUsageUpdatedNotification,
@@ -118,6 +120,7 @@ export interface StartCodexSessionArgs {
118
120
  model: string
119
121
  serviceTier?: ServiceTier
120
122
  sessionToken: string | null
123
+ pendingForkSessionToken?: string | null
121
124
  }
122
125
 
123
126
  export interface StartCodexTurnArgs {
@@ -745,7 +748,7 @@ export class CodexAppServerManager {
745
748
 
746
749
  async startSession(args: StartCodexSessionArgs) {
747
750
  const existing = this.sessions.get(args.chatId)
748
- if (existing && !existing.closed && existing.cwd === args.cwd) {
751
+ if (existing && !existing.closed && existing.cwd === args.cwd && !args.pendingForkSessionToken) {
749
752
  return
750
753
  }
751
754
 
@@ -791,8 +794,18 @@ export class CodexAppServerManager {
791
794
  persistExtendedHistory: false,
792
795
  } satisfies ThreadStartParams
793
796
 
794
- let response: ThreadStartResponse | ThreadResumeResponse
795
- if (args.sessionToken) {
797
+ let response: ThreadStartResponse | ThreadResumeResponse | ThreadForkResponse
798
+ if (args.pendingForkSessionToken) {
799
+ response = await this.sendRequest<ThreadForkResponse>(context, "thread/fork", {
800
+ threadId: args.pendingForkSessionToken,
801
+ model: args.model,
802
+ cwd: args.cwd,
803
+ serviceTier: args.serviceTier,
804
+ approvalPolicy: "never",
805
+ sandbox: "danger-full-access",
806
+ persistExtendedHistory: false,
807
+ } satisfies ThreadForkParams)
808
+ } else if (args.sessionToken) {
796
809
  try {
797
810
  response = await this.sendRequest<ThreadResumeResponse>(context, "thread/resume", {
798
811
  threadId: args.sessionToken,
@@ -815,6 +828,7 @@ export class CodexAppServerManager {
815
828
  }
816
829
 
817
830
  context.sessionToken = response.thread.id
831
+ return context.sessionToken
818
832
  }
819
833
 
820
834
  async startTurn(args: StartCodexTurnArgs): Promise<HarnessTurn> {
@@ -121,15 +121,6 @@ type SelectedBranch =
121
121
  remoteRef?: string
122
122
  }
123
123
 
124
- async function fileExists(filePath: string) {
125
- try {
126
- await stat(filePath)
127
- return true
128
- } catch {
129
- return false
130
- }
131
- }
132
-
133
124
  async function runGit(args: string[], cwd: string) {
134
125
  const process = Bun.spawn(["git", "-C", cwd, ...args], {
135
126
  stdout: "pipe",
@@ -511,4 +511,30 @@ describe("EventStore", () => {
511
511
  expect(pruned).toEqual([])
512
512
  expect(store.getChat(chat.id)?.id).toBe(chat.id)
513
513
  })
514
+
515
+ test("forks a chat with copied transcript and pending fork session token", async () => {
516
+ const dataDir = await createTempDataDir()
517
+ const store = new EventStore(dataDir)
518
+ await store.initialize()
519
+
520
+ const project = await store.openProject("/tmp/project")
521
+ const source = await store.createChat(project.id)
522
+ await store.setChatProvider(source.id, "claude")
523
+ await store.setPlanMode(source.id, true)
524
+ await store.setSessionToken(source.id, "session-1")
525
+ await store.appendMessage(source.id, entry("user_prompt", source.createdAt + 1, { content: "analyze this" }))
526
+ await store.appendMessage(source.id, entry("assistant_text", source.createdAt + 2, { text: "done" }))
527
+
528
+ const forked = await store.forkChat(source.id)
529
+
530
+ expect(forked.id).not.toBe(source.id)
531
+ expect(forked.title).toBe("Fork: New Chat")
532
+ expect(forked.provider).toBe("claude")
533
+ expect(forked.planMode).toBe(true)
534
+ expect(forked.sessionToken).toBeNull()
535
+ expect(forked.pendingForkSessionToken).toBe("session-1")
536
+ expect(forked.lastTurnOutcome).toBeNull()
537
+ expect(forked.lastMessageAt).toBeUndefined()
538
+ expect(store.getMessages(forked.id)).toEqual(store.getMessages(source.id))
539
+ })
514
540
  })
@@ -94,6 +94,8 @@ function getReplayEventPriority(event: StoreEvent) {
94
94
  return 5
95
95
  case "session_token_set":
96
96
  return 6
97
+ case "pending_fork_session_token_set":
98
+ return 6
97
99
  case "turn_cancelled":
98
100
  return 7
99
101
  case "turn_finished":
@@ -130,6 +132,12 @@ function getHistorySnapshot(page: TranscriptPageResult, recentLimit: number): Ch
130
132
  }
131
133
  }
132
134
 
135
+ function getForkedChatTitle(title: string) {
136
+ const trimmed = title.trim()
137
+ if (!trimmed) return "Fork: New Chat"
138
+ return trimmed.startsWith("Fork: ") ? trimmed : `Fork: ${trimmed}`
139
+ }
140
+
133
141
  export class EventStore {
134
142
  readonly dataDir: string
135
143
  readonly state: StoreState = createEmptyState()
@@ -220,6 +228,7 @@ export class EventStore {
220
228
  this.state.chatsById.set(chat.id, {
221
229
  ...chat,
222
230
  unread: chat.unread ?? false,
231
+ pendingForkSessionToken: chat.pendingForkSessionToken ?? null,
223
232
  })
224
233
  }
225
234
  this.legacySidebarProjectOrder = normalizeSidebarProjectOrder(parsed.sidebarProjectOrder)
@@ -443,6 +452,7 @@ export class EventStore {
443
452
  provider: null,
444
453
  planMode: false,
445
454
  sessionToken: null,
455
+ pendingForkSessionToken: null,
446
456
  hasMessages: false,
447
457
  lastTurnOutcome: null,
448
458
  }
@@ -555,6 +565,13 @@ export class EventStore {
555
565
  chat.updatedAt = event.timestamp
556
566
  break
557
567
  }
568
+ case "pending_fork_session_token_set": {
569
+ const chat = this.state.chatsById.get(event.chatId)
570
+ if (!chat) break
571
+ chat.pendingForkSessionToken = event.pendingForkSessionToken
572
+ chat.updatedAt = event.timestamp
573
+ break
574
+ }
558
575
  }
559
576
  }
560
577
 
@@ -677,6 +694,50 @@ export class EventStore {
677
694
  return this.state.chatsById.get(chatId)!
678
695
  }
679
696
 
697
+ async forkChat(sourceChatId: string) {
698
+ const sourceChat = this.requireChat(sourceChatId)
699
+ const sourceSessionToken = sourceChat.sessionToken ?? sourceChat.pendingForkSessionToken ?? null
700
+ if (!sourceChat.provider || !sourceSessionToken) {
701
+ throw new Error("Chat cannot be forked")
702
+ }
703
+
704
+ const chatId = crypto.randomUUID()
705
+ const createdAt = Date.now()
706
+ const createEvent: ChatEvent = {
707
+ v: STORE_VERSION,
708
+ type: "chat_created",
709
+ timestamp: createdAt,
710
+ chatId,
711
+ projectId: sourceChat.projectId,
712
+ title: getForkedChatTitle(sourceChat.title),
713
+ }
714
+ await this.append(this.chatsLogPath, createEvent)
715
+ await this.setChatProvider(chatId, sourceChat.provider)
716
+ await this.setPlanMode(chatId, sourceChat.planMode)
717
+ await this.setPendingForkSessionToken(chatId, sourceSessionToken)
718
+
719
+ const sourceEntries = this.getMessages(sourceChatId)
720
+ if (sourceEntries.length > 0) {
721
+ const transcriptPath = this.transcriptPath(chatId)
722
+ const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
723
+ this.writeChain = this.writeChain.then(async () => {
724
+ await mkdir(this.transcriptsDir, { recursive: true })
725
+ await writeFile(transcriptPath, `${payload}\n`, "utf8")
726
+ const chat = this.state.chatsById.get(chatId)
727
+ if (chat) {
728
+ chat.hasMessages = true
729
+ chat.updatedAt = Math.max(chat.updatedAt, createdAt)
730
+ }
731
+ if (this.cachedTranscript?.chatId === chatId) {
732
+ this.cachedTranscript = { chatId, entries: cloneTranscriptEntries(sourceEntries) }
733
+ }
734
+ })
735
+ await this.writeChain
736
+ }
737
+
738
+ return this.state.chatsById.get(chatId)!
739
+ }
740
+
680
741
  async renameChat(chatId: string, title: string) {
681
742
  const trimmed = title.trim()
682
743
  if (!trimmed) return
@@ -911,6 +972,19 @@ export class EventStore {
911
972
  await this.append(this.turnsLogPath, event)
912
973
  }
913
974
 
975
+ async setPendingForkSessionToken(chatId: string, pendingForkSessionToken: string | null) {
976
+ const chat = this.requireChat(chatId)
977
+ if ((chat.pendingForkSessionToken ?? null) === pendingForkSessionToken) return
978
+ const event: TurnEvent = {
979
+ v: STORE_VERSION,
980
+ type: "pending_fork_session_token_set",
981
+ timestamp: Date.now(),
982
+ chatId,
983
+ pendingForkSessionToken,
984
+ }
985
+ await this.append(this.turnsLogPath, event)
986
+ }
987
+
914
988
  getProject(projectId: string) {
915
989
  const project = this.state.projectsById.get(projectId)
916
990
  if (!project || project.deletedAt) return null
@@ -15,6 +15,7 @@ export interface ChatRecord {
15
15
  provider: AgentProvider | null
16
16
  planMode: boolean
17
17
  sessionToken: string | null
18
+ pendingForkSessionToken?: string | null
18
19
  hasMessages?: boolean
19
20
  lastMessageAt?: number
20
21
  lastTurnOutcome: "success" | "failed" | "cancelled" | null
@@ -152,6 +153,13 @@ export type TurnEvent =
152
153
  chatId: string
153
154
  sessionToken: string | null
154
155
  }
156
+ | {
157
+ v: 2
158
+ type: "pending_fork_session_token_set"
159
+ timestamp: number
160
+ chatId: string
161
+ pendingForkSessionToken: string | null
162
+ }
155
163
 
156
164
  export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | QueuedMessageEvent | TurnEvent
157
165
 
@@ -29,6 +29,7 @@ describe("read models", () => {
29
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)
@@ -246,4 +247,63 @@ describe("read models", () => {
246
247
  expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
247
248
  expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
248
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
+ })
249
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
  }
@@ -52,9 +64,11 @@ export function deriveSidebarData(
52
64
  options?: {
53
65
  nowMs?: number
54
66
  sidebarProjectOrder?: string[]
67
+ drainingChatIds?: Set<string>
55
68
  }
56
69
  ): SidebarData {
57
70
  const nowMs = options?.nowMs ?? Date.now()
71
+ const drainingChatIds = options?.drainingChatIds ?? new Set<string>()
58
72
  const chatsByProjectId = new Map<string, ChatRecord[]>()
59
73
  for (const chat of state.chatsById.values()) {
60
74
  if (chat.deletedAt) continue
@@ -94,6 +108,7 @@ export function deriveSidebarData(
94
108
  provider: chat.provider,
95
109
  lastMessageAt: chat.lastMessageAt,
96
110
  hasAutomation: false,
111
+ canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
97
112
  }))
98
113
  const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
99
114
 
@@ -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()
@@ -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: { getActiveStatuses: () => new Map() } as never,
743
+ agent: {
744
+ getActiveStatuses: () => new Map(),
745
+ getDrainingChatIds: () => new Set(),
746
+ } as never,
743
747
  terminals: {
744
748
  getSnapshot: () => null,
745
749
  onEvent: () => () => {},
@@ -942,6 +946,135 @@ describe("ws-router", () => {
942
946
  })
943
947
  })
944
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
+
945
1078
  test("prunes stale empty chats during explicit maintenance runs", async () => {
946
1079
  const state = createEmptyState()
947
1080
  state.projectsById.set("project-1", {
@@ -308,6 +308,7 @@ export function createWsRouter({
308
308
  const startedAt = performance.now()
309
309
  const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
310
310
  sidebarProjectOrder: getSidebarProjectOrder(store),
311
+ drainingChatIds: agent.getDrainingChatIds(),
311
312
  })
312
313
  if (isSendToStartingProfilingEnabled()) {
313
314
  const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
@@ -813,6 +814,12 @@ export function createWsRouter({
813
814
  await broadcastChatAndSidebar(chat.id)
814
815
  return
815
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
+ }
816
823
  case "chat.rename": {
817
824
  await store.renameChat(command.chatId, command.title)
818
825
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
@@ -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[] }
@@ -276,6 +276,7 @@ export interface SidebarChatRow {
276
276
  provider: AgentProvider | null
277
277
  lastMessageAt?: number
278
278
  hasAutomation: boolean
279
+ canFork?: boolean
279
280
  }
280
281
 
281
282
  export interface SidebarProjectGroup {