mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-02-23 14:44:28 +01:00
feat: implement general viewer ui
This commit is contained in:
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal 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
|
||||
@@ -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
1539
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
235
src/app/components/MarkdownContent.tsx
Normal file
235
src/app/components/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
147
src/app/projects/[projectId]/components/ProjectPage.tsx
Normal file
147
src/app/projects/[projectId]/components/ProjectPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/app/projects/[projectId]/hooks/useProject.ts
Normal file
15
src/app/projects/[projectId]/hooks/useProject.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
};
|
||||
40
src/app/projects/[projectId]/loading.tsx
Normal file
40
src/app/projects/[projectId]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/projects/[projectId]/page.tsx
Normal file
10
src/app/projects/[projectId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
77
src/app/projects/[projectId]/services/parseCommandXml.ts
Normal file
77
src/app/projects/[projectId]/services/parseCommandXml.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
58
src/app/projects/[projectId]/sessions/[sessionId]/page.tsx
Normal file
58
src/app/projects/[projectId]/sessions/[sessionId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/projects/components/ProjectList.tsx
Normal file
70
src/app/projects/components/ProjectList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/app/projects/hooks/useProjects.ts
Normal file
13
src/app/projects/hooks/useProjects.ts
Normal 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
26
src/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal 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 }
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal 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 }
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
44
src/components/ui/hover-card.tsx
Normal file
44
src/components/ui/hover-card.tsx
Normal 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 }
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
@@ -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)}` })
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user