-
Notifications
You must be signed in to change notification settings - Fork 990
fix(desktop): restore Claude session dropdown loading + click import #1795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5cf24cf
f2b0d67
77fa9c3
87c45a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,41 @@ | ||
| import { randomUUID } from "node:crypto"; | ||
| import { existsSync } from "node:fs"; | ||
| import { readFile, stat } from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { | ||
| createChatServiceRouter as buildRouter, | ||
| ChatService, | ||
| } from "@superset/chat/host"; | ||
| import { sessionStateSchema } from "@superset/chat/schema"; | ||
| import { convertExternalSessionToChatChunks } from "@superset/chat/shared"; | ||
| import { TRPCError } from "@trpc/server"; | ||
| import fg from "fast-glob"; | ||
| import { env } from "main/env.main"; | ||
| import { appState } from "main/lib/app-state"; | ||
| import { getHashedDeviceId } from "main/lib/device-info"; | ||
| import { notificationsEmitter } from "main/lib/notifications/server"; | ||
| import { NOTIFICATION_EVENTS } from "shared/constants"; | ||
| import { z } from "zod"; | ||
| import { publicProcedure, router } from "../.."; | ||
| import { loadToken } from "../auth/utils/auth-functions"; | ||
|
|
||
| interface ClaudeSessionSummary { | ||
| id: string; | ||
| title: string; | ||
| filePath: string; | ||
| projectId: string; | ||
| lastModifiedAt: string; | ||
| } | ||
|
|
||
| type FileStat = Awaited<ReturnType<typeof stat>>; | ||
| type ClaudeSessionRootKind = "projects" | "transcripts"; | ||
|
|
||
| interface ClaudeSessionRoot { | ||
| kind: ClaudeSessionRootKind; | ||
| rootDir: string; | ||
| } | ||
|
|
||
| function resolveLifecycleTargets(sessionId: string): Array<{ | ||
| paneId: string; | ||
| tabId: string; | ||
|
|
@@ -43,6 +70,318 @@ function resolveLifecycleTargets(sessionId: string): Array<{ | |
| return targets; | ||
| } | ||
|
|
||
| function getClaudeSessionRoots(): ClaudeSessionRoot[] { | ||
| const baseDir = path.join(os.homedir(), ".claude"); | ||
| return [ | ||
| { | ||
| kind: "projects", | ||
| rootDir: path.join(baseDir, "projects"), | ||
| }, | ||
| { | ||
| kind: "transcripts", | ||
| rootDir: path.join(baseDir, "transcripts"), | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| function encodeClaudeProjectPath(cwd: string): string { | ||
| const normalized = path.resolve(cwd).replace(/\\/g, "/"); | ||
| const withoutDrive = normalized.replace(/^[A-Za-z]:/, ""); | ||
| const segments = withoutDrive | ||
| .split("/") | ||
| .filter(Boolean) | ||
| .map((segment) => segment.replace(/[^A-Za-z0-9_-]/g, "-")); | ||
| return `-${segments.join("-")}`; | ||
| } | ||
|
|
||
| function isWithinDirectory(rootDir: string, targetPath: string): boolean { | ||
| const relative = path.relative(rootDir, targetPath); | ||
| return ( | ||
| relative.length > 0 && | ||
| !relative.startsWith("..") && | ||
| !path.isAbsolute(relative) | ||
| ); | ||
| } | ||
|
|
||
| function findClaudeSessionRootForPath( | ||
| targetPath: string, | ||
| ): ClaudeSessionRoot | null { | ||
| const normalizedFilePath = path.resolve(targetPath); | ||
| for (const root of getClaudeSessionRoots()) { | ||
| if (!existsSync(root.rootDir)) continue; | ||
| if (isWithinDirectory(root.rootDir, normalizedFilePath)) { | ||
| return root; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| async function listClaudeSessions(args: { | ||
| cwd: string; | ||
| limit: number; | ||
| }): Promise<ClaudeSessionSummary[]> { | ||
| const { cwd, limit } = args; | ||
| const roots = getClaudeSessionRoots().filter((root) => existsSync(root.rootDir)); | ||
| if (roots.length === 0) return []; | ||
|
|
||
|
Comment on lines
+122
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI formatter failure — fix formatting. The pipeline flags lines 122–126 as incorrectly formatted. Run 🧰 Tools🪛 GitHub Actions: CI[error] 122-126: Formatter would have printed the following content: fix formatting (prettier). 🤖 Prompt for AI Agents |
||
| const rootEntries = await Promise.all( | ||
| roots.map(async (root) => { | ||
| try { | ||
| const filePaths = await fg("**/*.jsonl", { | ||
| cwd: root.rootDir, | ||
| absolute: true, | ||
| onlyFiles: true, | ||
| unique: true, | ||
| followSymbolicLinks: false, | ||
| suppressErrors: true, | ||
| }); | ||
| return filePaths.map((filePath) => ({ filePath, root })); | ||
| } catch { | ||
| return [] as Array<{ filePath: string; root: ClaudeSessionRoot }>; | ||
| } | ||
| }), | ||
| ); | ||
|
|
||
| const allEntries = rootEntries.flat(); | ||
| if (allEntries.length === 0) return []; | ||
|
|
||
| const workspaceProjectId = encodeClaudeProjectPath(cwd); | ||
| const workspaceMatches = allEntries.filter(({ filePath, root }) => { | ||
| if (root.kind !== "projects") return false; | ||
| const relative = path.relative(root.rootDir, filePath).replace(/\\/g, "/"); | ||
| const [projectId] = relative.split("/"); | ||
| return projectId === workspaceProjectId; | ||
| }); | ||
|
|
||
| const candidates = workspaceMatches.length > 0 ? workspaceMatches : allEntries; | ||
|
|
||
| const withStats: Array<{ | ||
| filePath: string; | ||
| fileStat: FileStat; | ||
| root: ClaudeSessionRoot; | ||
| }> = []; | ||
| for (const { filePath, root } of candidates) { | ||
| try { | ||
| const fileStat = await stat(filePath); | ||
| withStats.push({ filePath, fileStat, root }); | ||
| } catch {} | ||
| } | ||
|
|
||
| return withStats | ||
| .sort((a, b) => b.fileStat.mtime.getTime() - a.fileStat.mtime.getTime()) | ||
| .slice(0, limit) | ||
| .map(({ filePath, fileStat, root }) => { | ||
| const projectId = | ||
| root.kind === "projects" | ||
| ? (path | ||
| .relative(root.rootDir, filePath) | ||
| .replace(/\\/g, "/") | ||
| .split("/")[0] ?? "unknown") | ||
| : "transcripts"; | ||
| const id = path.basename(filePath, ".jsonl"); | ||
| return { | ||
| id, | ||
| title: id, | ||
| filePath, | ||
| projectId, | ||
| lastModifiedAt: fileStat.mtime.toISOString(), | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| function getMessageTextFromChunk( | ||
| chunk: ReturnType< | ||
| typeof convertExternalSessionToChatChunks | ||
| >["messages"][number], | ||
| ): string { | ||
| for (const part of chunk.message.parts) { | ||
| if (part.type === "text" && typeof part.text === "string") { | ||
| return part.text.trim(); | ||
| } | ||
| } | ||
| return ""; | ||
| } | ||
|
|
||
| function deriveImportedSessionTitle(args: { | ||
| filePath: string; | ||
| messages: ReturnType<typeof convertExternalSessionToChatChunks>["messages"]; | ||
| }): string { | ||
| const { filePath, messages } = args; | ||
| const firstUserText = messages | ||
| .filter((message) => message.message.role === "user") | ||
| .map(getMessageTextFromChunk) | ||
| .find((text) => text.length > 0); | ||
| if (firstUserText) { | ||
| return firstUserText.slice(0, 120); | ||
| } | ||
| return path.basename(filePath, ".jsonl"); | ||
| } | ||
|
|
||
| async function getAuthHeaders(): Promise<Record<string, string>> { | ||
| const { token } = await loadToken(); | ||
| if (!token) { | ||
| throw new TRPCError({ | ||
| code: "UNAUTHORIZED", | ||
| message: "You must be signed in to import Claude sessions", | ||
| }); | ||
| } | ||
| return { | ||
| Authorization: `Bearer ${token}`, | ||
| }; | ||
| } | ||
|
|
||
| async function importClaudeSession(args: { | ||
| filePath: string; | ||
| organizationId: string; | ||
| workspaceId: string; | ||
| }): Promise<{ | ||
| sessionId: string; | ||
| title: string; | ||
| importedMessages: number; | ||
| ignoredEntries: number; | ||
| }> { | ||
| const normalizedFilePath = path.resolve(args.filePath); | ||
| const root = findClaudeSessionRootForPath(normalizedFilePath); | ||
| if (!root) { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "Invalid Claude session path", | ||
| }); | ||
| } | ||
| if (!normalizedFilePath.endsWith(".jsonl")) { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "Claude session file must be .jsonl", | ||
| }); | ||
| } | ||
|
|
||
| const sessionFileContent = await readFile(normalizedFilePath, "utf8"); | ||
| const converted = convertExternalSessionToChatChunks({ | ||
| input: sessionFileContent, | ||
| providerId: "claude-code", | ||
| }); | ||
| if (converted.messages.length === 0) { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "No importable messages found in Claude session", | ||
| }); | ||
| } | ||
|
Comment on lines
+258
to
+268
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the 🛡️ Proposed fix- const converted = convertExternalSessionToChatChunks({
- input: sessionFileContent,
- providerId: "claude-code",
- });
- if (converted.messages.length === 0) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "No importable messages found in Claude session",
- });
- }
+ let converted: ReturnType<typeof convertExternalSessionToChatChunks>;
+ try {
+ converted = convertExternalSessionToChatChunks({
+ input: sessionFileContent,
+ providerId: "claude-code",
+ });
+ } catch (err) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Failed to parse Claude session file: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ if (converted.messages.length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No importable messages found in Claude session",
+ });
+ }🤖 Prompt for AI Agents |
||
|
|
||
| const headers = await getAuthHeaders(); | ||
| const sessionId = randomUUID(); | ||
| const createResponse = await fetch( | ||
| `${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`, | ||
| { | ||
| method: "PUT", | ||
| headers: { | ||
| ...headers, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| organizationId: args.organizationId, | ||
| workspaceId: args.workspaceId, | ||
| }), | ||
| }, | ||
| ); | ||
| if (!createResponse.ok) { | ||
| const detail = await createResponse.text().catch(() => ""); | ||
| throw new TRPCError({ | ||
| code: "INTERNAL_SERVER_ERROR", | ||
| message: `Failed to create imported session (${createResponse.status}): ${detail || "unknown error"}`, | ||
| }); | ||
| } | ||
|
|
||
| const usedMessageIds = new Set<string>(); | ||
| let seqCounter = 0; | ||
| for (const [index, chunk] of converted.messages.entries()) { | ||
| const baseMessageId = | ||
| typeof chunk.message.id === "string" && chunk.message.id.trim().length > 0 | ||
| ? chunk.message.id.trim() | ||
| : `imported-${index}`; | ||
| let messageId = baseMessageId; | ||
| let duplicateCounter = 1; | ||
| while (usedMessageIds.has(messageId)) { | ||
| messageId = `${baseMessageId}-${duplicateCounter++}`; | ||
| } | ||
| usedMessageIds.add(messageId); | ||
|
|
||
| const createdAtRaw = chunk.message.createdAt; | ||
| const createdAt = | ||
| typeof createdAtRaw === "string" | ||
| ? createdAtRaw | ||
| : createdAtRaw instanceof Date | ||
| ? createdAtRaw.toISOString() | ||
| : new Date().toISOString(); | ||
| const role = chunk.message.role; | ||
| const actorId = | ||
| role === "user" | ||
| ? "imported-claude-user" | ||
| : role === "assistant" | ||
| ? "imported-claude-assistant" | ||
| : "imported-claude-system"; | ||
|
|
||
| const event = sessionStateSchema.chunks.insert({ | ||
| key: `${messageId}:0`, | ||
| value: { | ||
| messageId, | ||
| actorId, | ||
| role, | ||
| chunk: JSON.stringify({ | ||
| type: "whole-message", | ||
| message: { | ||
| ...chunk.message, | ||
| id: messageId, | ||
| createdAt, | ||
| }, | ||
| }), | ||
| seq: seqCounter++, | ||
| createdAt, | ||
| }, | ||
| }); | ||
|
|
||
| const appendResponse = await fetch( | ||
| `${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}/stream`, | ||
| { | ||
| method: "POST", | ||
| headers: { | ||
| ...headers, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify(event), | ||
| }, | ||
| ); | ||
| if (!appendResponse.ok) { | ||
| const detail = await appendResponse.text().catch(() => ""); | ||
| throw new TRPCError({ | ||
| code: "INTERNAL_SERVER_ERROR", | ||
| message: `Failed to append imported message (${appendResponse.status}): ${detail || "unknown error"}`, | ||
| }); | ||
| } | ||
| } | ||
|
Comment on lines
+294
to
+360
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sequential per-message HTTP POST with no timeout — slow imports and no partial-failure recovery. Each converted message is appended via a separate
Consider batching messages (if the API supports it), adding a timeout via 🤖 Prompt for AI Agents |
||
|
|
||
| const title = deriveImportedSessionTitle({ | ||
| filePath: normalizedFilePath, | ||
| messages: converted.messages, | ||
| }); | ||
| if (title.trim().length > 0) { | ||
| await fetch(`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`, { | ||
| method: "PATCH", | ||
| headers: { | ||
| ...headers, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ title: title.trim() }), | ||
| }).catch(() => {}); | ||
| } | ||
|
Comment on lines
+366
to
+375
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Title PATCH failure is silently swallowed — imported session may appear untitled. The ♻️ Proposed fix if (title.trim().length > 0) {
await fetch(`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`, {
method: "PATCH",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title.trim() }),
- }).catch(() => {});
+ }).catch((err) => {
+ console.warn(`[claude] failed to set imported session title: ${sessionId}`, err);
+ });
}🤖 Prompt for AI Agents |
||
|
|
||
| return { | ||
| sessionId, | ||
| title, | ||
| importedMessages: converted.messages.length, | ||
| ignoredEntries: converted.ignoredEntries, | ||
| }; | ||
| } | ||
|
|
||
| const service = new ChatService({ | ||
| deviceId: getHashedDeviceId(), | ||
| apiUrl: env.NEXT_PUBLIC_API_URL, | ||
|
|
@@ -69,6 +408,38 @@ const service = new ChatService({ | |
|
|
||
| export const createChatServiceRouter = () => buildRouter(service); | ||
|
|
||
| export const createChatServiceClaudeRouter = () => | ||
| router({ | ||
| listSessions: publicProcedure | ||
| .input( | ||
| z.object({ | ||
| cwd: z.string().min(1), | ||
| limit: z.number().int().min(1).max(200).default(30), | ||
| }), | ||
| ) | ||
| .query(async ({ input }) => { | ||
| return listClaudeSessions({ | ||
| cwd: input.cwd, | ||
| limit: input.limit, | ||
| }); | ||
| }), | ||
| importSession: publicProcedure | ||
| .input( | ||
| z.object({ | ||
| filePath: z.string().min(1), | ||
| organizationId: z.string().min(1), | ||
| workspaceId: z.string().min(1), | ||
| }), | ||
| ) | ||
| .mutation(async ({ input }) => { | ||
| return importClaudeSession({ | ||
| filePath: input.filePath, | ||
| organizationId: input.organizationId, | ||
| workspaceId: input.workspaceId, | ||
| }); | ||
| }), | ||
| }); | ||
|
Comment on lines
+411
to
+441
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🤖 Prompt for AI Agents |
||
|
|
||
| export type ChatServiceDesktopRouter = ReturnType< | ||
| typeof createChatServiceRouter | ||
| >; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isWithinDirectoryrejects the root directory itself (relative.length > 0), which is correct, but does not guard against symlink escapes.path.relativecomputes a textual relative path — if the target is a symlink pointing outsideclaudeProjectsRoot, the check passes but the resolved file is outside the intended boundary. SincefollowSymbolicLinks: falseis set infast-glob(line 103), listed paths won't follow symlinks, butimportClaudeSessionreceivesfilePathfrom user input directly and callspath.resolve+readFileon it.Consider using
fs.realpathonnormalizedFilePathbefore the containment check to guard against symlink-based traversal.🔒 Proposed fix
🤖 Prompt for AI Agents