feat: implement general viewer ui

This commit is contained in:
d-kimsuon
2025-08-30 02:50:55 +09:00
parent 0d5ac6c66f
commit 16ec283e50
37 changed files with 3420 additions and 12 deletions

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a web-based viewer for Claude Code conversation history files. The application provides a UI to browse and view JSONL conversation files from Claude Code projects stored in `~/.claude/projects/`.
## Development Commands
**Start development server:**
```bash
pnpm dev
```
This runs both Next.js (port 3400) and pathpida in watch mode for type-safe routing.
**Build and type checking:**
```bash
pnpm build
pnpm typecheck
```
**Linting and formatting:**
```bash
pnpm lint # Run all lint checks
pnpm fix # Fix all linting and formatting issues
```
**Testing:**
```bash
pnpm test # Run tests once
pnpm test:watch # Run tests in watch mode
```
## Architecture Overview
### Technology Stack
- **Frontend**: Next.js 15 with React 19, TypeScript
- **Backend**: Hono.js API routes (served via Next.js API routes)
- **Styling**: Tailwind CSS with shadcn/ui components
- **Data fetching**: TanStack Query (React Query)
- **Validation**: Zod schemas
- **Code formatting**: Biome (replaces ESLint + Prettier)
### Key Architecture Patterns
**Monorepo Structure**: Single Next.js app with integrated backend API
**API Layer**: Hono.js app mounted at `/api` with type-safe routes:
- `/api/projects` - List all Claude projects
- `/api/projects/:projectId` - Get project details and sessions
- `/api/projects/:projectId/sessions/:sessionId` - Get session conversations
**Data Flow**:
1. Backend reads JSONL files from `~/.claude/projects/`
2. Parses and validates conversation entries with Zod schemas
3. Frontend fetches via type-safe API client with React Query
**Type Safety**:
- Zod schemas for conversation data validation (`src/lib/conversation-schema/`)
- pathpida for type-safe routing (`src/lib/$path.ts`)
- Strict TypeScript configuration extending `@tsconfig/strictest`
### File Structure Patterns
**Conversation Schema** (`src/lib/conversation-schema/`):
- Modular Zod schemas for different conversation entry types
- Union types for flexible conversation parsing
- Separate schemas for content types, tools, and message formats
**Server Services** (`src/server/service/`):
- Project operations: `getProjects`, `getProject`, `getProjectMeta`
- Session operations: `getSessions`, `getSession`, `getSessionMeta`
- Parsing utilities: `parseJsonl`, `parseCommandXml`
**Frontend Structure**:
- Page components in app router structure
- Reusable UI components in `src/components/ui/`
- Custom hooks for data fetching (`useProject`, `useConversations`)
- Conversation display components in nested folders
### Data Sources
The application reads Claude Code history from:
- **Primary location**: `~/.claude/projects/` (defined in `src/server/service/paths.ts:4`)
- **File format**: JSONL files containing conversation entries
- **Structure**: Project folders containing session JSONL files
### Key Components
**Conversation Parsing**:
- JSONL parser validates each line against conversation schema
- Handles different entry types: User, Assistant, Summary, System
- Supports various content types: Text, Tool Use, Tool Result, Thinking
**Command Detection**:
- Parses XML-like command structures in conversation content
- Extracts command names and arguments for better display
- Handles different command formats (slash commands, local commands)
### Development Notes
- Uses `pathpida` for compile-time route validation
- Biome handles both linting and formatting (no ESLint/Prettier)
- Vitest for testing with global test setup
- TanStack Query for server state management with error boundaries

View File

@@ -22,6 +22,10 @@
"@auth/core": "^0.40.0",
"@hono/auth-js": "^1.1.0",
"@hono/zod-validator": "^0.7.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -31,6 +35,9 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-error-boundary": "^6.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.1",
"ulid": "^3.0.1",
"zod": "^4.1.5"
@@ -42,6 +49,7 @@
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react-syntax-highlighter": "^15.5.13",
"npm-run-all2": "^8.0.4",
"pathpida": "^0.25.0",
"tailwindcss": "^4.1.12",

1539
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
"use client";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { FC } from "react";
interface MarkdownContentProps {
content: string;
className?: string;
}
export const MarkdownContent: FC<MarkdownContentProps> = ({
content,
className = "",
}) => {
return (
<div
className={`prose prose-neutral dark:prose-invert max-w-none ${className}`}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1({ children, ...props }) {
return (
<h1
className="text-3xl font-bold mb-6 mt-8 pb-3 border-b border-border text-foreground"
{...props}
>
{children}
</h1>
);
},
h2({ children, ...props }) {
return (
<h2
className="text-2xl font-semibold mb-4 mt-8 pb-2 border-b border-border/50 text-foreground"
{...props}
>
{children}
</h2>
);
},
h3({ children, ...props }) {
return (
<h3
className="text-xl font-semibold mb-3 mt-6 text-foreground"
{...props}
>
{children}
</h3>
);
},
h4({ children, ...props }) {
return (
<h4
className="text-lg font-medium mb-2 mt-4 text-foreground"
{...props}
>
{children}
</h4>
);
},
h5({ children, ...props }) {
return (
<h5
className="text-base font-medium mb-2 mt-4 text-foreground"
{...props}
>
{children}
</h5>
);
},
h6({ children, ...props }) {
return (
<h6
className="text-sm font-medium mb-2 mt-4 text-muted-foreground"
{...props}
>
{children}
</h6>
);
},
p({ children, ...props }) {
return (
<p className="mb-4 leading-7 text-foreground" {...props}>
{children}
</p>
);
},
ul({ children, ...props }) {
return (
<ul className="mb-4 ml-6 list-disc space-y-2" {...props}>
{children}
</ul>
);
},
ol({ children, ...props }) {
return (
<ol className="mb-4 ml-6 list-decimal space-y-2" {...props}>
{children}
</ol>
);
},
li({ children, ...props }) {
return (
<li className="leading-7 text-foreground" {...props}>
{children}
</li>
);
},
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code
className="bg-muted/70 px-2 py-1 rounded-md text-sm font-mono text-foreground border"
{...props}
>
{children}
</code>
);
}
return (
<div className="relative my-6">
<div className="flex items-center justify-between bg-muted/30 px-4 py-2 border-b border-border rounded-t-lg">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{match[1]}
</span>
</div>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
className="!mt-0 !rounded-t-none !rounded-b-lg !border-t-0 !border !border-border"
customStyle={{
margin: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
);
},
pre({ children, ...props }) {
return <pre {...props}>{children}</pre>;
},
blockquote({ children, ...props }) {
return (
<blockquote
className="border-l-4 border-primary/30 bg-muted/30 pl-6 pr-4 py-4 my-6 italic rounded-r-lg"
{...props}
>
<div className="text-muted-foreground">{children}</div>
</blockquote>
);
},
a({ children, href, ...props }) {
return (
<a
href={href}
className="text-primary hover:text-primary/80 underline underline-offset-4 decoration-primary/30 hover:decoration-primary/60 transition-colors"
{...props}
>
{children}
</a>
);
},
// テーブルの改善
table({ children, ...props }) {
return (
<div className="overflow-x-auto my-6 rounded-lg border border-border">
<table className="min-w-full border-collapse" {...props}>
{children}
</table>
</div>
);
},
thead({ children, ...props }) {
return (
<thead className="bg-muted/50" {...props}>
{children}
</thead>
);
},
th({ children, ...props }) {
return (
<th
className="border-b border-border px-4 py-3 text-left font-semibold text-foreground"
{...props}
>
{children}
</th>
);
},
td({ children, ...props }) {
return (
<td
className="border-b border-border px-4 py-3 text-foreground"
{...props}
>
{children}
</td>
);
},
hr({ ...props }) {
return <hr className="my-8 border-t border-border" {...props} />;
},
strong({ children, ...props }) {
return (
<strong className="font-semibold text-foreground" {...props}>
{children}
</strong>
);
},
em({ children, ...props }) {
return (
<em className="italic text-foreground" {...props}>
{children}
</em>
);
},
}}
>
{content}
</Markdown>
</div>
);
};

View File

@@ -1,7 +1,6 @@
import { redirect } from "next/navigation";
import { pagesPath } from "../lib/$path";
export default function Home() {
return (
<div>
<h1>Empty App</h1>
</div>
);
redirect(pagesPath.projects.$url().pathname);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { FolderIcon, MessageSquareIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useProject } from "../hooks/useProject";
import { pagesPath } from "../../../../lib/$path";
import { parseCommandXml } from "../../../../server/service/parseCommandXml";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const {
data: { project, sessions },
} = useProject(projectId);
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link
href={pagesPath.projects.$url().pathname}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Projects
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
<FolderIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">
{project.meta.projectName ?? "unknown"}
</h1>
</div>
<p className="text-muted-foreground font-mono text-sm">
Workspace: {project.meta.projectPath ?? "unknown"}
</p>
<p className="text-muted-foreground font-mono text-sm">
Claude History: {project.claudeProjectPath ?? "unknown"}
</p>
</header>
<main>
<section>
<h2 className="text-xl font-semibold mb-4">
Conversation Sessions{" "}
{project.meta.sessionCount ? `(${project.meta.sessionCount})` : ""}
</h2>
{sessions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No sessions found</h3>
<p className="text-muted-foreground text-center max-w-md">
No conversation sessions found for this project. Start a
conversation with Claude Code in this project to create
sessions.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
{sessions.map((session) => (
<Card
key={session.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="break-all overflow-ellipsis line-clamp-2 text-xl">
{session.meta.firstContent
? (() => {
const parsed = parseCommandXml(
session.meta.firstContent
);
if (parsed.kind === "command") {
return (
<span>
{parsed.commandName} {parsed.commandArgs}
</span>
);
}
if (parsed.kind === "local-command-1") {
return (
<span>
{parsed.commandName} {parsed.commandMessage}
</span>
);
}
if (parsed.kind === "local-command-2") {
return <span>{parsed.stdout}</span>;
}
return <span>{session.meta.firstContent}</span>;
})()
: ""}
</span>
</CardTitle>
<CardDescription className="font-mono text-xs">
{session.id}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{session.meta.messageCount} messages
</p>
<p className="text-sm text-muted-foreground">
Last modified:{" "}
{session.meta.lastModifiedAt
? new Date(
session.meta.lastModifiedAt
).toLocaleDateString()
: ""}
</p>
<p className="text-xs text-muted-foreground font-mono">
{session.jsonlFilePath}
</p>
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id
)}`}
>
View Session
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
)}
</section>
</main>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../../lib/api/client";
export const useProject = (projectId: string) => {
return useSuspenseQuery({
queryKey: ["projects", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId },
});
return await response.json();
},
});
};

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function ProjectLoading() {
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<Skeleton className="h-9 w-32 mb-4" />
<div className="flex items-center gap-3 mb-2">
<Skeleton className="w-6 h-6" />
<Skeleton className="h-9 w-80" />
</div>
<Skeleton className="h-4 w-96" />
</header>
<main>
<section>
<Skeleton className="h-7 w-64 mb-4" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-10 w-full mt-4" />
</CardContent>
</Card>
))}
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ProjectPageContent } from "./components/ProjectPage";
interface ProjectPageProps {
params: Promise<{ projectId: string }>;
}
export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params;
return <ProjectPageContent projectId={projectId} />;
}

View File

@@ -0,0 +1,77 @@
const regExp = /<(?<tag>[^>]+)>(?<content>\s*[^<]*?\s*)<\/\k<tag>>/g;
export const parseCommandXml = (
content: string
):
| {
kind: "command";
commandName: string;
commandArgs: string;
commandMessage?: string;
}
| {
kind: "local-command-1";
commandName: string;
commandMessage: string;
}
| {
kind: "local-command-2";
stdout: string;
}
| {
kind: "text";
content: string;
} => {
const matches = Array.from(content.matchAll(regExp)).map((match) => {
return {
tag: match.groups?.tag,
content: match.groups?.content,
};
});
if (matches.length === 0) {
return {
kind: "text",
content,
};
}
const commandName = matches.find(
(match) => match.tag === "command-name"
)?.content;
const commandArgs = matches.find(
(match) => match.tag === "command-args"
)?.content;
const commandMessage = matches.find(
(match) => match.tag === "command-message"
)?.content;
const localCommandStdout = matches.find(
(match) => match.tag === "local-command-stdout"
)?.content;
switch (true) {
case commandName !== undefined && commandArgs !== undefined:
return {
kind: "command",
commandName,
commandArgs,
commandMessage: commandMessage,
};
case commandName !== undefined && commandMessage !== undefined:
return {
kind: "local-command-1",
commandName,
commandMessage,
};
case localCommandStdout !== undefined:
return {
kind: "local-command-2",
stdout: localCommandStdout,
};
default:
return {
kind: "text",
content,
};
}
};

View File

@@ -0,0 +1,147 @@
import { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
import { AssistantMessageContent } from "@/lib/conversation-schema/message/AssistantMessageSchema";
import { FC } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, Lightbulb, Settings, FileText } from "lucide-react";
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
export const AssistantConversationContent: FC<{
content: AssistantMessageContent;
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
}> = ({ content, getToolResult }) => {
if (content.type === "text") {
return (
<div className="w-full mx-2 my-6">
<MarkdownContent content={content.text} />
</div>
);
}
if (content.type === "thinking") {
return (
<Card className="bg-muted/50 border-dashed gap-2 py-3">
<Collapsible>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-colors py-0 px-4">
<div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Thinking</CardTitle>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="py-0 px-4">
<div className="text-sm text-muted-foreground whitespace-pre-wrap font-mono">
{content.thinking}
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
if (content.type === "tool_use") {
const toolResult = getToolResult(content.id);
return (
<Card className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20 gap-2 py-3 mb-2">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<CardTitle className="text-sm font-medium">Tool Use</CardTitle>
<Badge
variant="outline"
className="border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300"
>
{content.name}
</Badge>
</div>
<CardDescription className="text-xs">
Tool execution with ID: {content.id}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-0 px-4">
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
Input Parameters
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="bg-background rounded border p-2 mt-1">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(content.input, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
{toolResult && (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
Tool Result
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="bg-background rounded border p-2 mt-1">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(toolResult.content, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
);
}
if (content.type === "tool_result") {
return (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20 gap-2 py-3">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<CardTitle className="text-sm font-medium">Tool Result</CardTitle>
<Badge
variant="outline"
className="border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300"
>
Debug
</Badge>
</div>
</CardHeader>
<CardContent className="py-0 px-4">
<div className="bg-background rounded border p-2">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(content, null, 2)}
</pre>
</div>
</CardContent>
</Card>
);
}
return null;
};

View File

@@ -0,0 +1,74 @@
import type { Conversation } from "@/lib/conversation-schema";
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
import type { FC } from "react";
import { UserConversationContent } from "./UserConversationContent";
import { AssistantConversationContent } from "./AssistantConversationContent";
import { MetaConversationContent } from "./MetaConversationContent";
import { SystemConversationContent } from "./SystemConversationContent";
import { SummaryConversationContent } from "./SummaryConversationContent";
export const ConversationItem: FC<{
conversation: Conversation;
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
}> = ({ conversation, getToolResult }) => {
if (conversation.type === "summary") {
return (
<SummaryConversationContent>
{conversation.summary}
</SummaryConversationContent>
);
}
if (conversation.type === "system") {
return (
<SystemConversationContent>
{conversation.content}
</SystemConversationContent>
);
}
if (conversation.isSidechain) {
// sidechain = サブタスクのこと
// 別途ツール呼び出しの方で描画可能にするのでここでは表示しない
return null;
}
if (conversation.type === "user") {
const userConversationJsx =
typeof conversation.message.content === "string" ? (
<UserConversationContent content={conversation.message.content} />
) : (
<ul className="w-full">
{conversation.message.content.map((content) => (
<li key={content.toString()}>
<UserConversationContent content={content} />
</li>
))}
</ul>
);
return conversation.isMeta === true ? (
// 展開可能にしてデフォで非展開
<MetaConversationContent>{userConversationJsx}</MetaConversationContent>
) : (
userConversationJsx
);
}
if (conversation.type === "assistant") {
return (
<ul className="w-full">
{conversation.message.content.map((content) => (
<li key={content.toString()}>
<AssistantConversationContent
content={content}
getToolResult={getToolResult}
/>
</li>
))}
</ul>
);
}
return null;
};

View File

@@ -0,0 +1,84 @@
"use client";
import type { Conversation } from "@/lib/conversation-schema";
import type { FC } from "react";
import { useConversations } from "../../hooks/useConversations";
import { ConversationItem } from "./ConversationItem";
const getConversationKey = (conversation: Conversation) => {
if (conversation.type === "user") {
return `user_${conversation.uuid}`;
}
if (conversation.type === "assistant") {
return `assistant_${conversation.uuid}`;
}
if (conversation.type === "system") {
return `system_${conversation.uuid}`;
}
if (conversation.type === "summary") {
return `summary_${conversation.leafUuid}`;
}
throw new Error(`Unknown conversation type: ${conversation}`);
};
type ConversationListProps = {
projectId: string;
sessionId: string;
};
export const ConversationList: FC<ConversationListProps> = ({
projectId,
sessionId,
}) => {
const { conversations, getToolResult } = useConversations(
projectId,
sessionId
);
return (
<ul>
{conversations.flatMap((conversation) => {
const elm = (
<ConversationItem
key={getConversationKey(conversation)}
conversation={conversation}
getToolResult={getToolResult}
/>
);
if (elm === null) {
return [];
}
return [
<li
className={`w-full flex ${
conversation.type === "user"
? "justify-end"
: conversation.type === "assistant"
? "justify-start"
: "justify-center"
}`}
key={getConversationKey(conversation)}
>
<div
className={`${
conversation.type === "user"
? "w-[90%]"
: conversation.type === "assistant"
? "w-[90%]"
: "w-[100%]"
}`}
>
{elm}
</div>
</li>,
];
})}
</ul>
);
};

View File

@@ -0,0 +1,29 @@
import type { FC, PropsWithChildren } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
export const MetaConversationContent: FC<PropsWithChildren> = ({
children,
}) => {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
Meta Information
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="bg-background rounded border p-3 mt-2">
<pre className="text-xs overflow-x-auto">{children}</pre>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -0,0 +1,29 @@
import type { FC, PropsWithChildren } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
export const SummaryConversationContent: FC<PropsWithChildren> = ({
children,
}) => {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
Summarized
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="bg-background rounded border p-3 mt-2">
<pre className="text-xs overflow-x-auto">{children}</pre>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -0,0 +1,29 @@
import type { FC, PropsWithChildren } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
export const SystemConversationContent: FC<PropsWithChildren> = ({
children,
}) => {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
<h4 className="text-xs font-medium text-muted-foreground">
System Message
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="bg-background rounded border p-3 mt-2">
<pre className="text-xs overflow-x-auto">{children}</pre>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -0,0 +1,82 @@
import type { FC } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Terminal } from "lucide-react";
import { parseCommandXml } from "@/app/projects/[projectId]/services/parseCommandXml";
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
export const TextContent: FC<{ text: string }> = ({ text }) => {
const parsed = parseCommandXml(text);
if (parsed.kind === "command") {
return (
<Card className="border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20 gap-2 py-3">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-green-600 dark:text-green-400" />
<CardTitle className="text-sm font-medium">
Claude Code Command
</CardTitle>
<Badge
variant="outline"
className="border-green-300 text-green-700 dark:border-green-700 dark:text-green-300"
>
{parsed.commandName}
</Badge>
</div>
</CardHeader>
{parsed.commandArgs.trim() === "" ? null : (
<CardContent className="py-0 px-4">
<div className="space-y-2">
<div>
<span className="text-xs font-medium text-muted-foreground">
Arguments:
</span>
<div className="bg-background rounded border p-2 mt-1">
<code className="text-xs">{parsed.commandArgs}</code>
</div>
</div>
</div>
</CardContent>
)}
</Card>
);
}
if (parsed.kind === "local-command-1") {
return (
<Card className="border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20 gap-2 py-3">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-green-600 dark:text-green-400" />
<CardTitle className="text-sm font-medium">Local Command</CardTitle>
</div>
</CardHeader>
<CardContent className="py-0 px-4">
<pre className="text-xs overflow-x-auto">{parsed.commandMessage}</pre>
</CardContent>
</Card>
);
}
if (parsed.kind === "local-command-2") {
return (
<Card className="border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20 gap-2 py-3">
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-green-600 dark:text-green-400" />
<CardTitle className="text-sm font-medium">Local Command</CardTitle>
</div>
</CardHeader>
<CardContent className="py-0 px-4">
<pre className="text-xs overflow-x-auto">{parsed.stdout}</pre>
</CardContent>
</Card>
);
}
return (
<MarkdownContent className="w-full mx-2 my-6" content={parsed.content} />
);
};

View File

@@ -0,0 +1,81 @@
import type { UserMessageContent } from "@/lib/conversation-schema/message/UserMessageSchema";
import type { FC } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Image as ImageIcon, AlertCircle } from "lucide-react";
import { TextContent } from "./TextContent";
export const UserConversationContent: FC<{
content: UserMessageContent;
}> = ({ content }) => {
if (typeof content === "string") {
return <TextContent text={content} />;
}
if (content.type === "text") {
return <TextContent text={content.text} />;
}
if (content.type === "image") {
if (content.source.type === "base64") {
return (
<Card className="border-purple-200 bg-purple-50/50 dark:border-purple-800 dark:bg-purple-950/20">
<CardHeader>
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<CardTitle className="text-sm font-medium">Image</CardTitle>
<Badge
variant="outline"
className="border-purple-300 text-purple-700 dark:border-purple-700 dark:text-purple-300"
>
{content.source.media_type}
</Badge>
</div>
<CardDescription className="text-xs">
User uploaded image content
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden bg-background">
<img
src={`data:${content.source.media_type};base64,${content.source.data}`}
alt="User uploaded content"
className="max-w-full h-auto max-h-96 object-contain"
/>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<CardTitle className="text-sm font-medium">
Unsupported Media
</CardTitle>
<Badge variant="destructive">Error</Badge>
</div>
<CardDescription className="text-xs">
Media type not supported for display
</CardDescription>
</CardHeader>
</Card>
);
}
if (content.type === "tool_result") {
// ツール結果は Assistant の呼び出し側に添えるので
return null;
}
return null;
};

View File

@@ -0,0 +1,44 @@
import { useConversationsQuery } from "./useConversationsQuery";
import { useCallback, useMemo } from "react";
export const useConversations = (projectId: string, sessionId: string) => {
const query = useConversationsQuery(projectId, sessionId);
const toolResultMap = useMemo(() => {
const entries = query.data.session.conversations.flatMap((conversation) => {
if (conversation.type !== "user") {
return [];
}
if (typeof conversation.message.content === "string") {
return [];
}
return conversation.message.content.flatMap((message) => {
if (typeof message === "string") {
return [];
}
if (message.type !== "tool_result") {
return [];
}
return [[message.tool_use_id, message] as const];
});
});
return new Map(entries);
}, [query.data.session.conversations]);
const getToolResult = useCallback(
(toolUseId: string) => {
return toolResultMap.get(toolUseId);
},
[toolResultMap]
);
return {
conversations: query.data.session.conversations,
getToolResult,
};
};

View File

@@ -0,0 +1,19 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../../../../lib/api/client";
export const useConversationsQuery = (projectId: string, sessionId: string) => {
return useSuspenseQuery({
queryKey: ["conversations", sessionId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].$get({
param: {
projectId,
sessionId,
},
});
return response.json();
},
});
};

View File

@@ -0,0 +1,34 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function SessionLoading() {
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<Skeleton className="h-9 w-32 mb-4" />
<div className="flex items-center gap-3 mb-2">
<Skeleton className="w-6 h-6" />
<Skeleton className="h-9 w-64" />
</div>
<Skeleton className="h-4 w-80" />
</header>
<main>
<Card className="max-w-4xl mx-auto">
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" />
<div className="flex justify-center pt-4">
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import Link from "next/link";
import { ArrowLeftIcon, MessageSquareIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Metadata } from "next";
import { ConversationList } from "./components/conversationList/ConversationList";
type PageParams = {
projectId: string;
sessionId: string;
};
export async function generateMetadata({
params,
}: {
params: Promise<PageParams>;
}): Promise<Metadata> {
const { projectId, sessionId } = await params;
return {
title: `Session: ${sessionId.slice(0, 8)}...`,
description: `View conversation session ${projectId}/${sessionId}`,
};
}
interface SessionPageProps {
params: Promise<PageParams>;
}
export default async function SessionPage({ params }: SessionPageProps) {
const { projectId, sessionId } = await params;
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Session List
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
<MessageSquareIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">Conversation Session</h1>
</div>
<p className="text-muted-foreground font-mono text-sm">
Session ID: {sessionId}
</p>
</header>
<main>
<ConversationList projectId={projectId} sessionId={sessionId} />
</main>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import type { FC } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { FolderIcon } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useProjects } from "../hooks/useProjects";
export const ProjectList: FC = () => {
const { data: projects } = useProjects();
if (projects.length === 0) {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderIcon className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No projects found</h3>
<p className="text-muted-foreground text-center max-w-md">
No Claude Code projects found in your ~/.claude/projects directory.
Start a conversation with Claude Code to create your first project.
</p>
</CardContent>
</Card>;
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="hover:shadow-md transition-shadow">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderIcon className="w-5 h-5" />
<span className="truncate">
{project.meta.projectName ?? project.claudeProjectPath}
</span>
</CardTitle>
<CardDescription>
{project.meta.sessionCount} conversation
{project.meta.sessionCount !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
Last modified:{" "}
{project.meta.lastModifiedAt
? new Date(project.meta.lastModifiedAt).toLocaleDateString()
: ""}
</p>
<p className="text-xs text-muted-foreground font-mono truncate">
Workspace: {project.meta.projectPath ?? "unknown"}
</p>
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link href={`/projects/${encodeURIComponent(project.id)}`}>
View Conversations
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
);
};

View File

@@ -0,0 +1,13 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { honoClient } from "../../../lib/api/client";
export const useProjects = () => {
return useSuspenseQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await honoClient.api.projects.$get();
const { projects } = await response.json();
return projects;
},
});
};

26
src/app/projects/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { HistoryIcon } from "lucide-react";
import { ProjectList } from "./components/ProjectList";
export default async function ProjectsPage() {
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<HistoryIcon className="w-8 h-8" />
Claude Code History Viewer
</h1>
<p className="text-muted-foreground">
Browse your Claude Code conversation history and project interactions
</p>
</header>
<main>
<section>
<h2 className="text-xl font-semibold mb-4">Your Projects</h2>
<ProjectList />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -26,6 +26,17 @@ const buildSuffix = (url?: {
};
export const pagesPath = {
'projects': {
_projectId: (projectId: string | number) => ({
'sessions': {
_sessionId: (sessionId: string | number) => ({
$url: (url?: { hash?: string }) => ({ pathname: '/projects/[projectId]/sessions/[sessionId]' as const, query: { projectId, sessionId }, hash: url?.hash, path: `/projects/${projectId}/sessions/${sessionId}${buildSuffix(url)}` })
})
},
$url: (url?: { hash?: string }) => ({ pathname: '/projects/[projectId]' as const, query: { projectId }, hash: url?.hash, path: `/projects/${projectId}${buildSuffix(url)}` })
}),
$url: (url?: { hash?: string }) => ({ pathname: '/projects' as const, hash: url?.hash, path: `/projects${buildSuffix(url)}` })
},
$url: (url?: { hash?: string }) => ({ pathname: '/' as const, hash: url?.hash, path: `/${buildSuffix(url)}` })
};

View File

@@ -1,4 +1,4 @@
import { hc } from "hono/client";
import type { RouteType } from "../../server/hono/route";
export const honoClient = hc<RouteType>("/");
export const honoClient = hc<RouteType>("http://localhost:3400");

View File

@@ -1,6 +1,6 @@
import { statSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { basename, dirname, resolve } from "node:path";
import { parseJsonl } from "../parseJsonl";
import type { ProjectMeta } from "../types";
@@ -69,7 +69,7 @@ export const getProjectMeta = async (
}
const projectMeta: ProjectMeta = {
projectName: cwd ? dirname(cwd) : null,
projectName: cwd ? basename(cwd) : null,
projectPath: cwd,
lastModifiedAt: lastModifiedUnixTime
? new Date(lastModifiedUnixTime)

View File

@@ -1,5 +1,5 @@
import { readdir } from "node:fs/promises";
import { resolve } from "node:path";
import { basename, extname, resolve } from "node:path";
import { decodeProjectId } from "../project/id";
import type { Session } from "../types";
@@ -18,7 +18,7 @@ export const getSessions = async (
const fullPath = resolve(d.parentPath, d.name);
return {
id: d.name,
id: basename(fullPath, extname(fullPath)),
jsonlFilePath: fullPath,
meta: await getSessionMeta(fullPath),
};