mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-21 14:34:21 +01:00
feat: implement sub chain message view feature
This commit is contained in:
@@ -35,14 +35,11 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
<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"}
|
||||
{project.meta.projectPath ?? project.claudeProjectPath}
|
||||
</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"}
|
||||
History File: {project.claudeProjectPath ?? "unknown"}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import type { Conversation } from "@/lib/conversation-schema";
|
||||
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
|
||||
import { SidechainConversationModal } from "../conversationModal/SidechainConversationModal";
|
||||
import { AssistantConversationContent } from "./AssistantConversationContent";
|
||||
import { MetaConversationContent } from "./MetaConversationContent";
|
||||
import { SummaryConversationContent } from "./SummaryConversationContent";
|
||||
@@ -10,7 +11,14 @@ import { UserConversationContent } from "./UserConversationContent";
|
||||
export const ConversationItem: FC<{
|
||||
conversation: Conversation;
|
||||
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
|
||||
}> = ({ conversation, getToolResult }) => {
|
||||
isRootSidechain: (conversation: Conversation) => boolean;
|
||||
getSidechainConversations: (rootUuid: string) => Conversation[];
|
||||
}> = ({
|
||||
conversation,
|
||||
getToolResult,
|
||||
isRootSidechain,
|
||||
getSidechainConversations,
|
||||
}) => {
|
||||
if (conversation.type === "summary") {
|
||||
return (
|
||||
<SummaryConversationContent>
|
||||
@@ -30,7 +38,25 @@ export const ConversationItem: FC<{
|
||||
if (conversation.isSidechain) {
|
||||
// sidechain = サブタスクのこと
|
||||
// 別途ツール呼び出しの方で描画可能にするのでここでは表示しない
|
||||
return null;
|
||||
if (!isRootSidechain(conversation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidechainConversationModal
|
||||
conversation={conversation}
|
||||
sidechainConversations={getSidechainConversations(
|
||||
conversation.uuid,
|
||||
).map((original) => {
|
||||
if (original.type === "summary") return original;
|
||||
return {
|
||||
...original,
|
||||
isSidechain: false,
|
||||
};
|
||||
})}
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversation.type === "user") {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { FC } from "react";
|
||||
import type { Conversation } from "@/lib/conversation-schema";
|
||||
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
|
||||
import { useSidechain } from "../../hooks/useSidechain";
|
||||
import { ConversationItem } from "./ConversationItem";
|
||||
|
||||
const getConversationKey = (conversation: Conversation) => {
|
||||
@@ -34,6 +35,9 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
conversations,
|
||||
getToolResult,
|
||||
}) => {
|
||||
const { isRootSidechain, getSidechainConversations } =
|
||||
useSidechain(conversations);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{conversations.flatMap((conversation) => {
|
||||
@@ -42,13 +46,11 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
key={getConversationKey(conversation)}
|
||||
conversation={conversation}
|
||||
getToolResult={getToolResult}
|
||||
isRootSidechain={isRootSidechain}
|
||||
getSidechainConversations={getSidechainConversations}
|
||||
/>
|
||||
);
|
||||
|
||||
if (elm === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
<li
|
||||
className={`w-full flex ${
|
||||
|
||||
@@ -20,9 +20,7 @@ export const MetaConversationContent: FC<PropsWithChildren> = ({
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="bg-background rounded border p-3 mt-2">
|
||||
<pre className="text-xs overflow-x-auto">{children}</pre>
|
||||
</div>
|
||||
<div className="bg-background rounded border p-3 mt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Eye } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Conversation } from "@/lib/conversation-schema";
|
||||
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
|
||||
import { ConversationList } from "../conversationList/ConversationList";
|
||||
|
||||
type SidechainConversationModalProps = {
|
||||
conversation: Conversation;
|
||||
sidechainConversations: Conversation[];
|
||||
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
|
||||
};
|
||||
|
||||
const sidechainTitle = (conversations: Conversation[]): string => {
|
||||
const firstConversation = conversations.at(0);
|
||||
|
||||
const defaultTitle = `${conversations.length} conversations (${
|
||||
firstConversation?.type !== "summary" ? firstConversation?.uuid : ""
|
||||
})`;
|
||||
|
||||
if (!firstConversation) {
|
||||
return defaultTitle;
|
||||
}
|
||||
|
||||
if (firstConversation.type !== "user") {
|
||||
return defaultTitle;
|
||||
}
|
||||
|
||||
const textContent =
|
||||
typeof firstConversation.message.content === "string"
|
||||
? firstConversation.message.content
|
||||
: (() => {
|
||||
const firstContent = firstConversation.message.content.at(0);
|
||||
if (firstContent === undefined) return null;
|
||||
|
||||
if (typeof firstContent === "string") return firstContent;
|
||||
if (firstContent.type === "text") return firstContent.text;
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
return textContent ?? defaultTitle;
|
||||
};
|
||||
|
||||
export const SidechainConversationModal: FC<
|
||||
SidechainConversationModalProps
|
||||
> = ({ conversation, sidechainConversations, getToolResult }) => {
|
||||
const title = sidechainTitle(sidechainConversations);
|
||||
|
||||
const rootUuid =
|
||||
conversation.type !== "summary" ? conversation.uuid : conversation.leafUuid;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full mb-3">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<Eye className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
View Task: {title}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="!w-[1200px] !max-w-none max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{title.length > 100 ? `${title.slice(0, 100)}...` : title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Root UUID: {rootUuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ConversationList
|
||||
conversations={sidechainConversations}
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Conversation } from "@/lib/conversation-schema";
|
||||
|
||||
export const useSidechain = (conversations: Conversation[]) => {
|
||||
const conversationMap = useMemo(() => {
|
||||
return new Map<string, Conversation>(
|
||||
conversations
|
||||
.filter((conv) => conv.type !== "summary")
|
||||
.map((conv) => [conv.uuid, conv] as const),
|
||||
);
|
||||
}, [conversations]);
|
||||
|
||||
const getRootConversationRecursive = useCallback(
|
||||
(conversation: Conversation): Conversation => {
|
||||
if (conversation.type === "summary") {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
if (conversation.parentUuid === null) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
const parent = conversationMap.get(conversation.parentUuid);
|
||||
if (parent === undefined) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
return getRootConversationRecursive(parent);
|
||||
},
|
||||
[conversationMap],
|
||||
);
|
||||
|
||||
const sidechainConversationGroups = useMemo(() => {
|
||||
const filtered = conversations
|
||||
.filter((conv) => conv.type !== "summary")
|
||||
.filter((conv) => conv.isSidechain === true);
|
||||
|
||||
const groups = new Map<string, Conversation[]>();
|
||||
|
||||
for (const conv of filtered) {
|
||||
const rootConversation = getRootConversationRecursive(conv);
|
||||
|
||||
if (rootConversation.type === "summary") {
|
||||
// たぶんない
|
||||
continue;
|
||||
}
|
||||
|
||||
if (groups.has(rootConversation.uuid)) {
|
||||
groups.get(rootConversation.uuid)?.push(conv);
|
||||
} else {
|
||||
groups.set(rootConversation.uuid, [conv]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [conversations, getRootConversationRecursive]);
|
||||
|
||||
const isRootSidechain = useCallback(
|
||||
(conversation: Conversation) => {
|
||||
if (conversation.type === "summary") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return sidechainConversationGroups.has(conversation.uuid);
|
||||
},
|
||||
[sidechainConversationGroups],
|
||||
);
|
||||
|
||||
const getSidechainConversations = useCallback(
|
||||
(rootUuid: string) => {
|
||||
return sidechainConversationGroups.get(rootUuid) ?? [];
|
||||
},
|
||||
[sidechainConversationGroups],
|
||||
);
|
||||
|
||||
return {
|
||||
isRootSidechain,
|
||||
getSidechainConversations,
|
||||
};
|
||||
};
|
||||
@@ -40,10 +40,9 @@ export const ProjectList: FC = () => {
|
||||
{project.meta.projectName ?? project.claudeProjectPath}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{project.meta.sessionCount} conversation
|
||||
{project.meta.sessionCount !== 1 ? "s" : ""}
|
||||
</CardDescription>
|
||||
{project.meta.projectPath ? (
|
||||
<CardDescription>{project.meta.projectPath}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -52,14 +51,14 @@ export const ProjectList: FC = () => {
|
||||
? new Date(project.meta.lastModifiedAt).toLocaleDateString()
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
Workspace: {project.meta.projectPath ?? "unknown"}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages: {project.meta.sessionCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/projects/${encodeURIComponent(project.id)}`}>
|
||||
View Conversations
|
||||
View Sessions
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function ProjectsPage() {
|
||||
<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
|
||||
Claude Code Viewer
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse your Claude Code conversation history and project interactions
|
||||
|
||||
Reference in New Issue
Block a user