Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelogs/v2.3.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## What's new

A maintenance release focused on internal cleanup: shared hooks and components were extracted across the app to remove duplicated logic and shrink several oversized files. No new features, but it fixes a few UI consistency issues along the way.

### Fixes

- **Code, diff, and git views now recolour instantly on theme change**: Syntax-highlighted code blocks, the diff viewer, and the git panel read the theme once when they rendered and didn't update when you switched light/dark (or when the OS theme changed in "system" mode) — leaving stale colours until the view remounted. They now repaint immediately on a theme change.
- **More reliable navigation from links and chips**: Opening a note or task from a card's linked-notes list, a chat/agent reference chip, a flow node, the search panel, or the project overview occasionally raced the target view's load and failed to scroll to / select the item. These now consistently switch views and reveal the target.
- **Consistent syntax-highlight colours everywhere**: Code blocks in notes, the dashboard HTML editor, and PDF export all draw from a single shared palette, so a given token renders the same colour across every surface (and the PDF light-theme remap stays in sync).

### Changes

- **Medium-priority colour aligned to blue**: The medium priority level now renders blue (`--info`) consistently. Previously the class-based badges (flow task nodes, the node-edit modal, the top-bar project badge, the card-detail flag) showed amber while the board card stripes and analytics charts already used blue. They now all match.
- **Shared UI primitives**: Introduced reusable building blocks — a theme hook (`useIsDark`), a copy-to-clipboard hook, a navigation helper, a `Toggle` switch, and a node-type chip — and adopted them across chat, agent, notes, kanban, graph, insights, settings, and onboarding to replace ad-hoc copies.
- **Unified the card detail view**: The card-detail modal and the inline card-detail panel (shown beside chat) now share one implementation, so they always stay in sync.
- **Smaller, more focused modules**: The kanban board's archive view, the note PDF-export logic, and the shared markdown code-block renderer were split into their own files; the store's startup hydration paths now share a single restore routine. The agent chat pane's turn-complete and error handlers now share one transcript-save routine, and the session-list date formatter moved into the shared date helpers.
310 changes: 310 additions & 0 deletions docs/architecture-review.md

Large diffs are not rendered by default.

55 changes: 26 additions & 29 deletions src/components/agent/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { AgentMessageBubble } from "./AgentMessageBubble";
import { PlanTaskList } from "./PlanTaskList";
import { ContextRing } from "./ContextRing";
import { Tooltip } from "@/components/ui/tooltip";
import { CairnEvents } from "@/lib/events";
import { revealNote } from "@/lib/events";
import { resolvePromptContext } from "@/lib/context-resolver";
import type { TerminalSession, TokenBreakdown } from "@/types";

Expand Down Expand Up @@ -60,6 +60,28 @@ function extractCairnRef(
}
}

/**
* Persist the session's finalised message transcript to the backend. Reads the
* latest store state imperatively (so it is safe to call from IPC callbacks),
* drops still-streaming messages, and fire-and-forgets the save. Shared by the
* `onDone` and `onError` handlers, which previously inlined identical blocks.
*/
function persistPiTranscript(sessionId: string): void {
const msgs = useCairnStore.getState().terminalSessions.find((t) => t.sessionId === sessionId)?.piMessages ?? [];
const saveable = msgs.filter((m) => !m.isStreaming).map((m) => ({
id: m.id,
role: m.role,
content: m.content,
reasoning: m.reasoning ?? null,
toolCalls: m.toolCalls ?? null,
subagents: m.subagents ?? null,
timestamp: m.timestamp,
}));
window.electron?.piAgent.saveMessages(sessionId, saveable).catch((err) =>
console.error(`[AgentChatPane] failed to persist transcript for session ${sessionId}:`, err),
);
}


const NATIVE_AGENT_SLASH_COMMANDS = [
{
Expand Down Expand Up @@ -248,17 +270,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
setRetryInfo(null);
setIsCompacting(false);
// Persist the full message transcript after the turn completes
const msgs = useCairnStore.getState().terminalSessions.find((t) => t.sessionId === sessionId)?.piMessages ?? [];
const saveable = msgs.filter((m) => !m.isStreaming).map((m) => ({
id: m.id,
role: m.role,
content: m.content,
reasoning: m.reasoning ?? null,
toolCalls: m.toolCalls ?? null,
subagents: m.subagents ?? null,
timestamp: m.timestamp,
}));
window.electron?.piAgent.saveMessages(sessionId, saveable).catch(console.error);
persistPiTranscript(sessionId);
});

const unsubError = electron.piAgent.onError((e) => {
Expand All @@ -274,19 +286,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
});
setIsLoading(false);
// Persist the message transcript including the error message
setTimeout(() => {
const msgs = useCairnStore.getState().terminalSessions.find((t) => t.sessionId === sessionId)?.piMessages ?? [];
const saveable = msgs.filter((m) => !m.isStreaming).map((m) => ({
id: m.id,
role: m.role,
content: m.content,
reasoning: m.reasoning ?? null,
toolCalls: m.toolCalls ?? null,
subagents: m.subagents ?? null,
timestamp: m.timestamp,
}));
window.electron?.piAgent.saveMessages(sessionId, saveable).catch(console.error);
}, 0);
setTimeout(() => persistPiTranscript(sessionId), 0);
});

// ── Subagent events (child session IDs routed back to parent) ──────────
Expand Down Expand Up @@ -596,10 +596,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) {
{session.planNoteId && (
<Tooltip content="Open plan note" side="left">
<button
onClick={() => {
setView("notes");
setTimeout(() => window.dispatchEvent(CairnEvents.selectNote(session.planNoteId!)), 50);
}}
onClick={() => revealNote(setView, session.planNoteId!)}
className="flex items-center gap-1 text-[0.643rem] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1.5 py-0.5 rounded-full border border-[var(--border)] hover:bg-[var(--surface-2)] transition-colors"
>
<FileText size={9} />
Expand Down
6 changes: 2 additions & 4 deletions src/components/agent/AgentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { useState, useCallback } from "react";
import { X, Save, FileCode, Eye, ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useIsDark } from "@/hooks/useIsDark";
import { useCairnStore } from "@/store";
import { useShallow } from "zustand/react/shallow";
import { NoteMarkdownPreview } from "@/components/notes/NoteMarkdownPreview";
Expand Down Expand Up @@ -41,10 +42,7 @@ export function AgentEditor() {
// Preview content cache: path → content string
const [previewContent, setPreviewContent] = useState<Record<string, string>>({});

const isDark =
typeof document !== "undefined"
? document.documentElement.getAttribute("data-theme") !== "light"
: true;
const isDark = useIsDark();

const handleDirtyChange = useCallback((path: string, dirty: boolean) => {
setDirtyFiles((prev) => {
Expand Down
5 changes: 2 additions & 3 deletions src/components/agent/AgentEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
import { useShallow } from "zustand/react/shallow";
import { Plus, Bot, History, ArrowRight } from "lucide-react";
import { useCairnStore } from "@/store";
import { cn } from "@/lib/utils";
import { cn, formatDateCompact } from "@/lib/utils";
import { useAgentSessionActions } from "./useAgentSessionActions";
import { formatDate } from "./sessionUtils";

export function AgentEmptyState() {
const { piSessionHistory, persistentPiSessionId } = useCairnStore(useShallow((s) => ({
Expand Down Expand Up @@ -58,7 +57,7 @@ export function AgentEmptyState() {
>
<div className="flex-1 min-w-0">
<p className="text-[0.714rem] text-[var(--text-secondary)] truncate">{summary.taskTitle}</p>
<p className="text-[0.607rem] text-[var(--text-tertiary)]">{formatDate(summary.updatedAt)}</p>
<p className="text-[0.607rem] text-[var(--text-tertiary)]">{formatDateCompact(summary.updatedAt)}</p>
</div>
<ArrowRight size={10} className="text-[var(--text-tertiary)] shrink-0 opacity-0 group-hover:opacity-100" />
</button>
Expand Down
5 changes: 2 additions & 3 deletions src/components/agent/AgentSessionTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { ChevronDown, History, MessageSquare, Trash2 } from "lucide-react";
import { useCairnStore } from "@/store";
import { cn } from "@/lib/utils";
import { cn, formatDateCompact } from "@/lib/utils";
import { useAgentSessionActions } from "./useAgentSessionActions";
import { formatDate } from "./sessionUtils";
import type { PiSessionSummary } from "@/types";

interface AgentSessionTabProps {
Expand Down Expand Up @@ -123,7 +122,7 @@ export function AgentSessionTab({ isActive, onActivate }: AgentSessionTabProps)
)}>
{summary.mode.toUpperCase()}
</span>
<span className="text-[0.607rem] text-[var(--text-tertiary)]">{formatDate(summary.updatedAt)}</span>
<span className="text-[0.607rem] text-[var(--text-tertiary)]">{formatDateCompact(summary.updatedAt)}</span>
{summary.status === "exited" && (
<span className="text-[0.607rem] text-[var(--text-tertiary)]">· exited</span>
)}
Expand Down
16 changes: 6 additions & 10 deletions src/components/agent/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useMemo, useState, useEffect, useCallback, useRef, useDeferredValue } f
import parseDiff from "parse-diff";
import { Copy, Check, RefreshCw, FolderGit2, ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useIsDark } from "@/hooks/useIsDark";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Tooltip } from "@/components/ui/tooltip";
import { FileDiff, PALETTE_DARK, PALETTE_LIGHT } from "./DiffFile";
import type { ViewMode } from "./DiffFile";
Expand Down Expand Up @@ -43,17 +45,14 @@ interface DiffViewerProps {
}

export function DiffViewer({ cwd }: DiffViewerProps) {
const isDark =
typeof document !== "undefined"
? document.documentElement.getAttribute("data-theme") !== "light"
: true;
const isDark = useIsDark();
const palette = isDark ? PALETTE_DARK : PALETTE_LIGHT;

const [diffText, setDiffText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [copied, setCopied] = useState(false);
const { copied, copy } = useCopyToClipboard();
const [mode, setMode] = useState<ViewMode>("unified");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const isMounted = useRef(true);
Expand Down Expand Up @@ -106,11 +105,8 @@ export function DiffViewer({ cwd }: DiffViewerProps) {
const expandAll = useCallback(() => setCollapsed(new Set()), []);

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(diffText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [diffText]);
copy(diffText);
}, [diffText, copy]);

// ── Toolbar ────────────────────────────────────────────────────────────────
const toolbar = (
Expand Down
6 changes: 2 additions & 4 deletions src/components/agent/GitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { RefreshCw, GitBranch, GitCommit, ArrowUp, Sparkles, File, Check, X, ChevronRight, ChevronDown, GitPullRequest, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { useIsDark } from "@/hooks/useIsDark";
import { useCairnStore } from "@/store";
import { useShallow } from "zustand/react/shallow";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -1059,10 +1060,7 @@ function FileRow({
}

function Inlinediff({ rawDiff, loading }: { rawDiff: string; loading: boolean }) {
const isDark =
typeof document !== "undefined"
? document.documentElement.getAttribute("data-theme") !== "light"
: true;
const isDark = useIsDark();
const palette = isDark ? PALETTE_DARK : PALETTE_LIGHT;

const parsed = useMemo(() => {
Expand Down
20 changes: 0 additions & 20 deletions src/components/agent/sessionUtils.ts

This file was deleted.

8 changes: 3 additions & 5 deletions src/components/chat/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useCairnStore } from "@/store";
import { useShallow } from "zustand/react/shallow";
import { NoteEditor } from "@/components/notes/note-editor";
import { CardDetailPanel } from "@/components/kanban/card-detail-panel";
import { CairnEvents } from "@/lib/events";
import { revealNote, revealCard } from "@/lib/events";
import { Button } from "@/components/ui/button";

const MIN_PREVIEW_WIDTH = 360;
Expand Down Expand Up @@ -79,11 +79,9 @@ export function PreviewPane() {

function handleGoToSection() {
if (type === "note" && note) {
setView("notes");
setTimeout(() => window.dispatchEvent(CairnEvents.selectNote(note.id)), 50);
revealNote(setView, note.id);
} else if (type === "task" && card) {
setView("board");
setTimeout(() => window.dispatchEvent(CairnEvents.openCard(card.id)), 50);
revealCard(setView, card.id);
}
setActivePreviewItem(null);
}
Expand Down
12 changes: 5 additions & 7 deletions src/components/chat/chat-panel/ChatMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useState } from "react";
import React from "react";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { FileText, Kanban, FolderOpen, Search, Copy, Check, RotateCcw, CheckCircle } from "lucide-react";
import { cn, formatRelative, prettifyToolLabel } from "@/lib/utils";
import { MarkdownContent } from "./MarkdownContent";
Expand Down Expand Up @@ -31,13 +32,10 @@ interface ChatMessageBubbleProps {
export const ChatMessageBubble = React.memo(function ChatMessageBubble({ message, onRetry }: ChatMessageBubbleProps) {
const isUser = message.role === "user";
const isSystem = message.role === "system";
const [copied, setCopied] = useState(false);
const { copied, copy } = useCopyToClipboard();

function handleCopy() {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
copy(message.content);
}

if (isSystem) {
Expand Down Expand Up @@ -85,7 +83,7 @@ export const ChatMessageBubble = React.memo(function ChatMessageBubble({ message
</div>
)}
<div className={cn("px-3 py-2.5 rounded-xl text-xs leading-relaxed max-w-full",
isUser ? "bg-[var(--accent)] text-white rounded-tr-sm" : "bg-[var(--surface-2)] border border-[var(--border)] text-[var(--text-secondary)] rounded-tl-sm")}>
isUser ? "bg-[var(--accent)] text-[var(--accent-fg)] rounded-tr-sm" : "bg-[var(--surface-2)] border border-[var(--border)] text-[var(--text-secondary)] rounded-tl-sm")}>
<MarkdownContent content={message.content} isUser={isUser} />
</div>
{!isUser && message.actions && message.actions.length > 0 && (
Expand Down
Loading