From 70f8c00e5e7126d7a23be1ab774079d28c56db65 Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Sun, 1 Mar 2026 13:25:25 +1000 Subject: [PATCH] :sparkles: chat history --- src/renderer/components/AIChat/Chat.css | 100 +++++++++++-- src/renderer/components/AIChat/ChatPanel.tsx | 74 ++++++++-- src/renderer/store/editorStore.ts | 143 ++++++++++++++----- src/renderer/types/editor.ts | 6 + 4 files changed, 268 insertions(+), 55 deletions(-) diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index 9784d5e..7069456 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -63,11 +63,6 @@ line-height: 1.4; } -/* Push Clear button to the far right inside the tab bar */ -.chat-tabs .chat-clear-btn { - margin-left: auto; -} - .chat-header { display: flex; align-items: center; @@ -82,22 +77,111 @@ flex-shrink: 0; } -.chat-clear-btn { +/* ── Chat sub-header (History link + New Chat button) ────────────── */ +.chat-subheader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.chat-history-link { + background: none; + border: none; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 3px 0; + cursor: pointer; + transition: color 0.15s; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: transparent; +} + +.chat-history-link:hover, +.chat-history-link-active { + color: var(--text-primary); + text-decoration-color: currentColor; +} + +.chat-new-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-muted); font-size: 10px; - padding: 2px 7px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 3px 8px; cursor: pointer; transition: color 0.15s, border-color 0.15s; } -.chat-clear-btn:hover { +.chat-new-btn:hover { color: var(--text-primary); border-color: var(--text-muted); } +/* ── History list ────────────────────────────────────────────────── */ +.chat-history-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-history-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + padding: 9px 11px; + border-radius: 6px; + background: none; + border: 1px solid transparent; + cursor: pointer; + text-align: left; + width: 100%; + transition: background 0.12s, border-color 0.12s; +} + +.chat-history-item:hover { + background: var(--message-bg); +} + +.chat-history-item-active { + border-color: var(--border); + background: var(--message-bg); +} + +.chat-history-time { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +.chat-history-summary { + font-size: 12px; + color: var(--text-muted); + line-height: 1.45; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.chat-history-item-active .chat-history-summary { + color: var(--text-secondary); +} + /* ── Pending attachment indicator (between tab bar and messages) ─── */ .chat-attachment-indicator { display: flex; diff --git a/src/renderer/components/AIChat/ChatPanel.tsx b/src/renderer/components/AIChat/ChatPanel.tsx index 4000384..47403c5 100644 --- a/src/renderer/components/AIChat/ChatPanel.tsx +++ b/src/renderer/components/AIChat/ChatPanel.tsx @@ -9,9 +9,23 @@ import './Chat.css' type TabId = 'chat' | 'feedback' +function formatSessionTime(createdAt: number): string { + const date = new Date(createdAt) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + const isYesterday = date.toDateString() === yesterday.toDateString() + if (isToday) return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (isYesterday) return 'Yesterday' + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +} + export function ChatPanel(): JSX.Element { const { chatHistory, + chatSessionsByFile, + activeSessionIdByFile, isAILoading, aiError, activeFileContent, @@ -26,14 +40,19 @@ export function ChatPanel(): JSX.Element { setAIError, setAnnotations, linkAnnotationsToMessage, - clearChat + newChat, + setActiveSession } = useEditorStore() const [tab, setTab] = useState('chat') + const [showHistory, setShowHistory] = useState(false) const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0) const scrollRef = useRef(null) const prevAnnotationCountRef = useRef(annotations.length) + // Collapse history when switching files + useEffect(() => { setShowHistory(false) }, [activeFilePath]) + // Auto-switch to Feedback tab the first time annotations appear (0 → >0) useEffect(() => { const prev = prevAnnotationCountRef.current @@ -103,6 +122,12 @@ export function ChatPanel(): JSX.Element { const allAnnotationsForFile: TextAnnotation[] = (activeFilePath ? annotationsByFile[activeFilePath]?.annotations : undefined) ?? [] + const sessions = (activeFilePath ? chatSessionsByFile[activeFilePath] : undefined) ?? [] + const activeSessionId = activeFilePath ? activeSessionIdByFile[activeFilePath] : undefined + + // Subheader is shown in the chat tab once there is at least one session + const showSubheader = tab === 'chat' && sessions.length > 0 + return (
{/* ── Tab bar ── */} @@ -122,15 +147,25 @@ export function ChatPanel(): JSX.Element { {annotations.length} )} - - {/* Clear button floated right, only visible in Chat tab */} - {tab === 'chat' && chatHistory.length > 0 && ( - - )}
+ {/* ── Chat sub-header: History link + New Chat button ── */} + {showSubheader && ( +
+ + {!showHistory && chatHistory.length > 0 && ( + + )} +
+ )} + {/* ── Pending-attachment indicator ── */} {pendingAttachmentCount > 0 && (
@@ -142,6 +177,29 @@ export function ChatPanel(): JSX.Element { {/* ── Panel content ── */} {tab === 'feedback' ? ( + ) : showHistory ? ( +
+ {[...sessions].reverse().map((session) => { + const isActive = session.id === activeSessionId + const firstUserMsg = session.messages.find(m => m.role === 'user') + const summary = firstUserMsg + ? firstUserMsg.content.slice(0, 80) + (firstUserMsg.content.length > 80 ? '…' : '') + : 'Empty conversation' + return ( + + ) + })} +
) : ( <>
diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index 40ec258..f7c2541 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta, AttachmentMeta } from '../types/editor' +import type { FileNode, ChatMessage, ChatSession, TextAnnotation, AnalysisMode, RevisionMeta, AttachmentMeta } from '../types/editor' interface AnnotationFileState { mode: AnalysisMode @@ -19,8 +19,9 @@ interface EditorState { setContent: (content: string) => void markSaved: () => void - // Chat - persisted per file path - chatHistoryByFile: Record + // Chat - persisted per file path, multiple sessions per file + chatSessionsByFile: Record + activeSessionIdByFile: Record chatHistory: ChatMessage[] isAILoading: boolean aiError: string | null @@ -29,7 +30,8 @@ interface EditorState { appendToLastAssistantMessage: (chunk: string) => void setAILoading: (loading: boolean) => void setAIError: (error: string | null) => void - clearChat: () => void + newChat: () => void + setActiveSession: (sessionId: string) => void // Annotations (highlights in editor) — also persisted per file annotations: TextAnnotation[] @@ -128,7 +130,10 @@ export const useEditorStore = create((set, get) => ({ isDirty: false, setActiveFile: (path, content) => { const s = get() - const existing = s.chatHistoryByFile[path] ?? [] + const sessions = s.chatSessionsByFile[path] ?? [] + const activeId = s.activeSessionIdByFile[path] + const activeSession = sessions.find(sess => sess.id === activeId) ?? sessions[sessions.length - 1] + const existing = activeSession?.messages ?? [] const savedAnnotationState = s.annotationsByFile[path] set({ activeFilePath: path, @@ -143,7 +148,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -151,7 +157,8 @@ export const useEditorStore = create((set, get) => ({ setContent: (content) => set({ activeFileContent: content, isDirty: true }), markSaved: () => set({ isDirty: false }), - chatHistoryByFile: {}, + chatSessionsByFile: {}, + activeSessionIdByFile: {}, chatHistory: [], isAILoading: false, aiError: null, @@ -160,17 +167,30 @@ export const useEditorStore = create((set, get) => ({ const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments } set((s) => { const history = [...s.chatHistory, msg] - const byFile = s.activeFilePath - ? { ...s.chatHistoryByFile, [s.activeFilePath]: history } - : s.chatHistoryByFile - return { chatHistory: history, chatHistoryByFile: byFile } + if (!s.activeFilePath) return { chatHistory: history } + let sessions = s.chatSessionsByFile[s.activeFilePath] ?? [] + let activeId = s.activeSessionIdByFile[s.activeFilePath] + // If no session exists yet, create the first one + if (sessions.length === 0 || !activeId) { + const newSession: ChatSession = { id: `session-${Date.now()}`, createdAt: Date.now(), messages: history } + return { + chatHistory: history, + chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: [newSession] }, + activeSessionIdByFile: { ...s.activeSessionIdByFile, [s.activeFilePath]: newSession.id } + } + } + const updatedSessions = sessions.map(sess => + sess.id === activeId ? { ...sess, messages: history } : sess + ) + return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: updatedSessions } } }) scheduleSave(() => { const st = get() return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -180,10 +200,12 @@ export const useEditorStore = create((set, get) => ({ const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' } set((s) => { const history = [...s.chatHistory, msg] - const byFile = s.activeFilePath - ? { ...s.chatHistoryByFile, [s.activeFilePath]: history } - : s.chatHistoryByFile - return { chatHistory: history, chatHistoryByFile: byFile } + if (!s.activeFilePath) return { chatHistory: history } + const activeId = s.activeSessionIdByFile[s.activeFilePath] + const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess => + sess.id === activeId ? { ...sess, messages: history } : sess + ) + return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions } } }) }, @@ -194,10 +216,12 @@ export const useEditorStore = create((set, get) => ({ if (last?.role === 'assistant') { history[history.length - 1] = { ...last, content: last.content + chunk } } - const byFile = s.activeFilePath - ? { ...s.chatHistoryByFile, [s.activeFilePath]: history } - : s.chatHistoryByFile - return { chatHistory: history, chatHistoryByFile: byFile } + if (!s.activeFilePath) return { chatHistory: history } + const activeId = s.activeSessionIdByFile[s.activeFilePath] + const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess => + sess.id === activeId ? { ...sess, messages: history } : sess + ) + return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions } } }) }, @@ -210,7 +234,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -218,24 +243,41 @@ export const useEditorStore = create((set, get) => ({ }, setAIError: (aiError) => set({ aiError }), - clearChat: () => { + newChat: () => { set((s) => { - const byFile = s.activeFilePath - ? { ...s.chatHistoryByFile, [s.activeFilePath]: [] } - : s.chatHistoryByFile - return { chatHistory: [], chatHistoryByFile: byFile } + if (!s.activeFilePath) return {} + const newSession: ChatSession = { id: `session-${Date.now()}`, createdAt: Date.now(), messages: [] } + const existingSessions = s.chatSessionsByFile[s.activeFilePath] ?? [] + return { + chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: [...existingSessions, newSession] }, + activeSessionIdByFile: { ...s.activeSessionIdByFile, [s.activeFilePath]: newSession.id }, + chatHistory: [] + } }) scheduleSave(() => { const st = get() return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) }, + setActiveSession: (sessionId: string) => { + set((s) => { + if (!s.activeFilePath) return {} + const session = (s.chatSessionsByFile[s.activeFilePath] ?? []).find(sess => sess.id === sessionId) + if (!session) return {} + return { + activeSessionIdByFile: { ...s.activeSessionIdByFile, [s.activeFilePath]: sessionId }, + chatHistory: session.messages + } + }) + }, + annotations: [], annotationsByFile: {}, setAnnotations: (annotations) => { @@ -255,7 +297,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -273,7 +316,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -290,7 +334,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -316,7 +361,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -327,17 +373,20 @@ export const useEditorStore = create((set, get) => ({ const history = s.chatHistory.map(m => m.id === messageId ? { ...m, annotationIds } : m ) - const byFile = s.activeFilePath - ? { ...s.chatHistoryByFile, [s.activeFilePath]: history } - : s.chatHistoryByFile - return { chatHistory: history, chatHistoryByFile: byFile } + if (!s.activeFilePath) return { chatHistory: history } + const activeId = s.activeSessionIdByFile[s.activeFilePath] + const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess => + sess.id === activeId ? { ...sess, messages: history } : sess + ) + return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions } } }) scheduleSave(() => { const st = get() return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -361,7 +410,8 @@ export const useEditorStore = create((set, get) => ({ return { activeFilePath: st.activeFilePath, scrollPositions: st.scrollPositions, - chatHistoryByFile: st.chatHistoryByFile, + chatSessionsByFile: st.chatSessionsByFile, + activeSessionIdByFile: st.activeSessionIdByFile, annotationsByFile: st.annotationsByFile } }) @@ -402,8 +452,23 @@ export const useEditorStore = create((set, get) => ({ if (data.scrollPositions && typeof data.scrollPositions === 'object') { patch.scrollPositions = data.scrollPositions as Record } - if (data.chatHistoryByFile && typeof data.chatHistoryByFile === 'object') { - patch.chatHistoryByFile = data.chatHistoryByFile as Record + if (data.chatSessionsByFile && typeof data.chatSessionsByFile === 'object') { + patch.chatSessionsByFile = data.chatSessionsByFile as Record + patch.activeSessionIdByFile = (data.activeSessionIdByFile as Record) ?? {} + } else if (data.chatHistoryByFile && typeof data.chatHistoryByFile === 'object') { + // Migrate from old flat format — wrap each file's history in a single session + const legacy = data.chatHistoryByFile as Record + const sessions: Record = {} + const activeIds: Record = {} + for (const [path, messages] of Object.entries(legacy)) { + if (Array.isArray(messages) && messages.length > 0) { + const id = `session-migrated-${Date.now()}` + sessions[path] = [{ id, createdAt: Date.now(), messages }] + activeIds[path] = id + } + } + patch.chatSessionsByFile = sessions + patch.activeSessionIdByFile = activeIds } if (data.annotationsByFile && typeof data.annotationsByFile === 'object') { patch.annotationsByFile = data.annotationsByFile as Record diff --git a/src/renderer/types/editor.ts b/src/renderer/types/editor.ts index 9313424..4a4e575 100644 --- a/src/renderer/types/editor.ts +++ b/src/renderer/types/editor.ts @@ -22,6 +22,12 @@ export interface ChatMessage { annotationIds?: string[] // IDs of suggestions this message produced } +export interface ChatSession { + id: string + createdAt: number + messages: ChatMessage[] +} + export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique' | 'custom' export interface TextAnnotation {