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
5 changes: 5 additions & 0 deletions .changeset/changelog-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"think-app": patch
---

Add in-app changelog viewer with version display in sidebar
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ backend/native_host/think-native-stub
app/public/ffmpeg/

# PDF.js worker (copied by vite config at build time)
app/public/pdf.worker.min.mjs
app/public/pdf.worker.min.mjs

# Generated changelog (parsed from CHANGELOG.md at build time)
app/src/data/changelog.json
91 changes: 91 additions & 0 deletions app/scripts/parse-changelog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const changelogPath = path.resolve(__dirname, '../CHANGELOG.md');
const outputPath = path.resolve(__dirname, '../src/data/changelog.json');

function parseChangelog(content) {
const entries = [];
const lines = content.split('\n');

let currentVersion = null;
let currentChangeType = null;
let currentChanges = [];
let currentDescription = [];

const flushDescription = () => {
if (currentDescription.length > 0) {
const text = currentDescription.join('\n').trim();
if (text) {
currentChanges.push({
type: currentChangeType,
description: text,
});
}
currentDescription = [];
}
};

const flushVersion = () => {
flushDescription();
if (currentVersion && currentChanges.length > 0) {
entries.push({
version: currentVersion,
changes: currentChanges,
});
}
currentChanges = [];
currentChangeType = null;
};

for (const line of lines) {
// Version header: ## 0.6.2
const versionMatch = line.match(/^## (\d+\.\d+\.\d+)/);
if (versionMatch) {
flushVersion();
currentVersion = versionMatch[1];
continue;
}

// Change type header: ### Minor Changes or ### Patch Changes
const changeTypeMatch = line.match(/^### (Minor|Patch|Major) Changes/i);
if (changeTypeMatch) {
flushDescription();
currentChangeType = changeTypeMatch[1].toLowerCase();
continue;
}

// Change item: - commit: description
const changeMatch = line.match(/^- ([a-f0-9]+: )?(.+)/);
if (changeMatch && currentChangeType) {
flushDescription();
currentDescription.push(changeMatch[2]);
continue;
}

// Continuation of description (indented or empty lines within a change)
if (currentDescription.length > 0 && (line.startsWith(' ') || line.trim() === '')) {
currentDescription.push(line);
}
}

flushVersion();

return entries;
}

// Read and parse
const content = fs.readFileSync(changelogPath, 'utf-8');
const entries = parseChangelog(content);

// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}

// Write JSON
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2));
console.log(`Parsed ${entries.length} changelog entries to ${outputPath}`);
131 changes: 131 additions & 0 deletions app/src/components/ChangelogDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import changelog from "@/data/changelog.json";

interface ChangelogEntry {
version: string;
changes: {
type: "minor" | "patch" | "major";
description: string;
}[];
}

interface ChangelogDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function ChangelogDialog({ open, onOpenChange }: ChangelogDialogProps) {
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);

// Prevent body scroll when dialog is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);

if (!open) return null;

const entries = changelog as ChangelogEntry[];

return createPortal(
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>

{/* Dialog panel */}
<div className="absolute left-1/2 top-[10%] -translate-x-1/2 w-full max-w-lg px-4">
<div
className={cn(
"bg-background rounded-2xl shadow-2xl border flex flex-col",
"transform transition-all duration-200",
"max-h-[80vh]"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b shrink-0">
<h2 className="text-lg font-semibold">Changelog</h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>

{/* Scrollable content */}
<div className="overflow-y-auto p-6 space-y-6 min-h-0">
{entries.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No changelog entries found
</p>
)}
{entries.map((entry) => (
<div key={entry.version} className="space-y-3">
{/* Version header */}
<div className="flex items-center gap-2">
<span className="text-base font-semibold">v{entry.version}</span>
{entry.changes.some((c) => c.type === "major") && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-red-500/10 text-red-600 dark:text-red-400">
Major
</span>
)}
{entry.changes.some((c) => c.type === "minor") && !entry.changes.some((c) => c.type === "major") && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
Minor
</span>
)}
{entry.changes.every((c) => c.type === "patch") && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
Patch
</span>
)}
</div>

{/* Changes */}
<div className="space-y-2 pl-1">
{entry.changes.map((change, idx) => (
<div key={idx} className="changelog-prose text-sm text-muted-foreground">
<ReactMarkdown>{change.description}</ReactMarkdown>
</div>
))}
</div>

{/* Divider (except for last entry) */}
{entry !== entries[entries.length - 1] && (
<div className="border-b border-border/50 pt-2" />
)}
</div>
))}
</div>
</div>
</div>
</div>,
document.body
);
}
20 changes: 20 additions & 0 deletions app/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { Home, Brain, Settings, MessageSquare, Network } from "lucide-react";
import ProviderStatusIndicator from "./ProviderStatusIndicator";
import { ChangelogDialog } from "./ChangelogDialog";
import { useConversation } from "@/contexts/ConversationContext";
import { sidebar } from "@/lib/design-tokens";
import { cn } from "@/lib/utils";
Expand All @@ -9,6 +11,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import changelog from "@/data/changelog.json";

const navItems = [
{ to: "/", icon: Home, label: "Home" },
Expand All @@ -20,6 +23,8 @@ const navItems = [

export default function Sidebar() {
const { startNewChat } = useConversation();
const [showChangelog, setShowChangelog] = useState(false);
const currentVersion = (changelog as { version: string }[])[0]?.version ?? "0.0.0";

const handleNavClick = (to: string) => {
if (to === "/") {
Expand Down Expand Up @@ -77,6 +82,21 @@ export default function Sidebar() {
</nav>

<ProviderStatusIndicator />

{/* Version footer */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setShowChangelog(true)}
className="px-2 py-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
v{currentVersion}
</button>
</TooltipTrigger>
<TooltipContent side="right">View changelog</TooltipContent>
</Tooltip>

<ChangelogDialog open={showChangelog} onOpenChange={setShowChangelog} />
</aside>
);
}
26 changes: 26 additions & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,32 @@
.chat-prose blockquote {
@apply border-l-2 border-primary/50 pl-3 italic my-2;
}

/* Changelog markdown prose styling */
.changelog-prose {
line-height: 1.6;
}
.changelog-prose p {
margin: 0.375em 0;
}
.changelog-prose p:first-child {
margin-top: 0;
}
.changelog-prose ul {
margin: 0.375em 0;
padding-left: 1.25em;
list-style-type: disc;
}
.changelog-prose li {
margin: 0.25em 0;
}
.changelog-prose code {
@apply bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-[0.85em] font-mono;
}
.changelog-prose strong {
font-weight: 600;
color: hsl(var(--foreground));
}
}

/* TipTap Editor prose styling - OUTSIDE @layer to prevent Tailwind purging */
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export async function getGraphData(filters: GraphFilters = {}): Promise<GraphDat
const params = new URLSearchParams();

if (filters.type && filters.type !== "all") {
params.append("type", filters.type);
params.append("memory_type", filters.type);
}
if (filters.date_range && filters.date_range !== "all") {
params.append("date_range", filters.date_range);
Expand Down
4 changes: 4 additions & 0 deletions app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import path from 'path';
import { createRequire } from 'module';
import fs from 'fs';
import { execSync } from 'child_process';

const require = createRequire(import.meta.url);

// Parse changelog at build time
execSync('node scripts/parse-changelog.js', { cwd: __dirname, stdio: 'inherit' });

// Resolve pdfjs-dist worker path dynamically (works with pnpm)
// Normalize to forward slashes for cross-platform compatibility with vite-plugin-static-copy
const pdfjsDistPath = path.dirname(require.resolve('pdfjs-dist/package.json'));
Expand Down
Loading