diff --git a/README.md b/README.md index bbf7284..db02853 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,6 @@ chats.](docs/images/command-palette.png) - **Keyboard-first navigation.** A command palette (Ctrl/Cmd+K) reaches new chat, settings, theme, model switching, and recent chats; `?` opens a shortcuts reference. The sidebar also supports searching chats by title. -- **Transcript export.** Any conversation can be exported as Markdown or JSON - from the workspace header. - **Durable context.** Threads live in SQLite; permanent memories use atomic local JSON storage. Existing JSON chat history and Windows settings are read additively during migration without rewriting the legacy source. diff --git a/docs/images/workspace.png b/docs/images/workspace.png index fc9df03..6c66f8e 100644 Binary files a/docs/images/workspace.png and b/docs/images/workspace.png differ diff --git a/frontend/src/features/chat/ChatPage.tsx b/frontend/src/features/chat/ChatPage.tsx index 17092cc..92de760 100644 --- a/frontend/src/features/chat/ChatPage.tsx +++ b/frontend/src/features/chat/ChatPage.tsx @@ -143,15 +143,6 @@ export function ChatPage({ const currentChat = threadId !== null && chat?.id === threadId ? chat : null; - // Mirror the fully-loaded, currently-viewed chat into the global store so - // chrome outside ChatPage (the AppShell header's export control) can read - // it without prop-drilling. Cleared on unmount so a Settings round-trip - // doesn't leave a stale chat exported from the header. - useEffect(() => { - useChatStore.getState().setActiveChat(currentChat); - return () => useChatStore.getState().setActiveChat(null); - }, [currentChat]); - useEffect(() => { if (isNearTranscriptEnd.current) { messageListRef.current?.scrollToBottom(); diff --git a/frontend/src/features/chat/ExportTranscriptMenu.tsx b/frontend/src/features/chat/ExportTranscriptMenu.tsx deleted file mode 100644 index d0c68d6..0000000 --- a/frontend/src/features/chat/ExportTranscriptMenu.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FileJson, FileText } from "lucide-react"; -import type { ChatResponse } from "../../../../contracts/cortex-api"; -import { exportTranscriptAsJson, exportTranscriptAsMarkdown } from "./exportTranscript"; - -export function ExportTranscriptMenu({ chat }: { chat: ChatResponse }) { - return ( -
- - -
- ); -} diff --git a/frontend/src/features/chat/exportTranscript.test.ts b/frontend/src/features/chat/exportTranscript.test.ts deleted file mode 100644 index 3300eb2..0000000 --- a/frontend/src/features/chat/exportTranscript.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ChatResponse } from "../../../../contracts/cortex-api"; -import { exportTranscriptAsJson, exportTranscriptAsMarkdown, transcriptAsJson, transcriptAsMarkdown } from "./exportTranscript"; - -const chat: ChatResponse = { - id: "thread-1", - title: "Weekend plans", - timestamp: "2026-01-01T00:00:00Z", - revision: 2, - messages: [ - { id: "m-1", role: "user", content: "What should I do this weekend?" }, - { id: "m-2", role: "assistant", content: "Consider a hike." }, - ], -}; - -describe("transcriptAsMarkdown", () => { - it("renders each message as a labeled section separated by a rule", () => { - const markdown = transcriptAsMarkdown(chat); - expect(markdown).toBe( - "### You\n\nWhat should I do this weekend?\n\n---\n\n### Cortex\n\nConsider a hike.", - ); - }); - - it("handles a chat with no messages", () => { - expect(transcriptAsMarkdown({ ...chat, messages: [] })).toBe(""); - }); -}); - -describe("transcriptAsJson", () => { - it("round-trips the full chat structure", () => { - expect(JSON.parse(transcriptAsJson(chat))).toEqual(chat); - }); -}); - -describe("export downloads", () => { - const originalCreateObjectURL = URL.createObjectURL; - const originalRevokeObjectURL = URL.revokeObjectURL; - - afterEach(() => { - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; - vi.restoreAllMocks(); - }); - - it("exportTranscriptAsMarkdown triggers a sanitized .md download", () => { - URL.createObjectURL = vi.fn(() => "blob:mock"); - URL.revokeObjectURL = vi.fn(); - const click = vi.fn(); - const anchor = document.createElement("a"); - vi.spyOn(anchor, "click").mockImplementation(click); - vi.spyOn(document, "createElement").mockReturnValue(anchor); - - exportTranscriptAsMarkdown({ ...chat, title: "Weekend: plans?" }); - - expect(anchor.download).toBe("Weekend_ plans_.md"); - expect(click).toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock"); - }); - - it("exportTranscriptAsJson triggers a .json download", () => { - URL.createObjectURL = vi.fn(() => "blob:mock"); - URL.revokeObjectURL = vi.fn(); - const click = vi.fn(); - const anchor = document.createElement("a"); - vi.spyOn(anchor, "click").mockImplementation(click); - vi.spyOn(document, "createElement").mockReturnValue(anchor); - - exportTranscriptAsJson(chat); - - expect(anchor.download).toBe("Weekend plans.json"); - expect(click).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/features/chat/exportTranscript.ts b/frontend/src/features/chat/exportTranscript.ts deleted file mode 100644 index 3d1ae39..0000000 --- a/frontend/src/features/chat/exportTranscript.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ChatResponse } from "../../../../contracts/cortex-api"; - -export function transcriptAsMarkdown(chat: ChatResponse): string { - const lines = (chat.messages ?? []).map((message) => { - const label = message.role === "assistant" ? "Cortex" : message.role === "user" ? "You" : "System"; - return `### ${label}\n\n${message.content}`; - }); - return lines.join("\n\n---\n\n"); -} - -export function transcriptAsJson(chat: ChatResponse): string { - return JSON.stringify(chat, null, 2); -} - -function sanitizeFilename(name: string): string { - return name.replace(/[/\\:*?"<>|]/g, "_").trim() || "chat"; -} - -function downloadBlob(filename: string, content: string, mimeType: string): void { - const url = URL.createObjectURL(new Blob([content], { type: mimeType })); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - URL.revokeObjectURL(url); -} - -export function exportTranscriptAsMarkdown(chat: ChatResponse): void { - downloadBlob(`${sanitizeFilename(chat.title)}.md`, transcriptAsMarkdown(chat), "text/markdown"); -} - -export function exportTranscriptAsJson(chat: ChatResponse): void { - downloadBlob(`${sanitizeFilename(chat.title)}.json`, transcriptAsJson(chat), "application/json"); -} diff --git a/frontend/src/features/shell/AppShell.test.tsx b/frontend/src/features/shell/AppShell.test.tsx index 0d0602c..128fa83 100644 --- a/frontend/src/features/shell/AppShell.test.tsx +++ b/frontend/src/features/shell/AppShell.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChatGroup, ChatSummary, ModelResponse } from "../../../../contracts/cortex-api"; -import { useChatStore } from "../../stores/useChatStore"; import { AppShell } from "./AppShell"; const CONNECTED = { success: true, status: "connected", message: "Connected." } satisfies NonNullable; @@ -117,20 +116,4 @@ describe("AppShell", () => { expect(screen.getByText("No chats match your search.")).toBeInTheDocument(); }); - - it("shows an export control in the header once a full chat is the active chat in the store", () => { - useChatStore.getState().setActiveChat({ - id: "chat-1", - title: "Quarterly planning", - timestamp: "2026-01-01T00:00:00Z", - revision: 1, - messages: [{ id: "m-1", role: "user", content: "hi" }], - }); - const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" }; - - renderShell({ chats: [chat], activeChatId: chat.id, onDeleteChat: vi.fn<(id: string) => Promise>().mockResolvedValue(), onOpenSettings: vi.fn() }); - - expect(screen.getByRole("button", { name: "Export as Markdown" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Export as JSON" })).toBeInTheDocument(); - }); }); diff --git a/frontend/src/features/shell/AppShell.tsx b/frontend/src/features/shell/AppShell.tsx index 9f89eff..5c93d83 100644 --- a/frontend/src/features/shell/AppShell.tsx +++ b/frontend/src/features/shell/AppShell.tsx @@ -3,11 +3,9 @@ import { Menu, Plus, Search, Settings, Trash2 } from "lucide-react"; import type { ChatGroup, ChatSummary, CodeExecutionSourceResponse, ExecutionApprovalDecisionRequest, ExecutionTaskSummary, ModelResponse } from "../../../../contracts/cortex-api"; import { displayChatTitle } from "../../lib/chatTitle"; import { chatPath, parseAppRoute, useNavigate, usePathname } from "../../lib/navigation"; -import { useChatStore } from "../../stores/useChatStore"; import { AlertDialog, Dialog, DialogContent } from "../../shared/ui/Dialog"; import { ExecutionTaskTray } from "./ExecutionTaskTray"; import { ChatLibrary } from "./ChatLibrary"; -import { ExportTranscriptMenu } from "../chat/ExportTranscriptMenu"; import { NavigationLink } from "./NavigationLink"; type Props = { @@ -53,7 +51,6 @@ export function AppShell({ }: Props) { const navigate = useNavigate(); const pathname = usePathname(); - const activeChat = useChatStore((state) => state.activeChat); const [sidebarVisible, setSidebarVisible] = useState(() => !isCompactWindow()); const [renameTarget, setRenameTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -110,7 +107,6 @@ export function AppShell({
- {!isSettings && activeChat && } { beforeEach(() => { - useChatStore.setState({ chats: [], activeChat: null, generation: idleGeneration, generationOptionsByThread: {} }); + useChatStore.setState({ chats: [], generation: idleGeneration, generationOptionsByThread: {} }); }); it("setChats accepts a plain array, mirroring useState", () => { diff --git a/frontend/src/stores/useChatStore.ts b/frontend/src/stores/useChatStore.ts index b023560..e6b5cd7 100644 --- a/frontend/src/stores/useChatStore.ts +++ b/frontend/src/stores/useChatStore.ts @@ -30,13 +30,11 @@ interface ChatStoreState { chats: ChatSummary[]; /** User-created folders/projects, in their persisted display order. */ groups: ChatGroup[]; - activeChat: ChatResponse | null; generation: GenerationState; generationOptionsByThread: Record; setChats: (next: ChatSummary[] | ((current: ChatSummary[]) => ChatSummary[])) => void; upsertChatSummary: (chat: ChatResponse) => void; - setActiveChat: (chat: ChatResponse | null) => void; setGroups: (next: ChatGroup[] | ((current: ChatGroup[]) => ChatGroup[])) => void; /** Replace one group in place, keeping its position in the list. */ @@ -78,7 +76,6 @@ const idleGeneration: GenerationState = { export const useChatStore = create((set) => ({ chats: [], groups: [], - activeChat: null, generation: idleGeneration, generationOptionsByThread: {}, @@ -99,7 +96,6 @@ export const useChatStore = create((set) => ({ ...state.chats.filter((item) => item.id !== chat.id), ], })), - setActiveChat: (chat) => set({ activeChat: chat }), setGroups: (next) => set((state) => ({ groups: typeof next === "function" ? (next as (current: ChatGroup[]) => ChatGroup[])(state.groups) : next })),