feat: implement sub chain message view feature

This commit is contained in:
d-kimsuon
2025-08-30 15:00:02 +09:00
parent bfa19a6e85
commit bd0aeb490b
14 changed files with 592 additions and 44 deletions

View File

@@ -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>

View File

@@ -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") {

View File

@@ -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 ${

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -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>

View File

@@ -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