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

@@ -24,6 +24,7 @@
"@hono/zod-validator": "^0.7.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.85.5",

188
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -639,6 +642,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@@ -652,6 +668,28 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies:
@@ -1100,6 +1138,10 @@ packages:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -1221,6 +1263,9 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -1274,6 +1319,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1714,6 +1763,36 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.7.1:
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-syntax-highlighter@15.6.6:
resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==}
peerDependencies:
@@ -1912,6 +1991,26 @@ packages:
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sync-external-store@1.5.0:
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
@@ -2385,6 +2484,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
aria-hidden: 1.2.6
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2398,6 +2519,23 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.12)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2777,6 +2915,10 @@ snapshots:
ansi-styles@6.2.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
bail@2.0.2: {}
@@ -2877,6 +3019,8 @@ snapshots:
detect-libc@2.0.4: {}
detect-node-es@1.1.0: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -2942,6 +3086,8 @@ snapshots:
fsevents@2.3.3:
optional: true
get-nonce@1.0.1: {}
graceful-fs@4.2.11: {}
hast-util-parse-selector@2.2.5: {}
@@ -3584,6 +3730,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-remove-scroll-bar@2.3.8(@types/react@19.1.12)(react@19.1.1):
dependencies:
react: 19.1.1
react-style-singleton: 2.2.3(@types/react@19.1.12)(react@19.1.1)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.1.12
react-remove-scroll@2.7.1(@types/react@19.1.12)(react@19.1.1):
dependencies:
react: 19.1.1
react-remove-scroll-bar: 2.3.8(@types/react@19.1.12)(react@19.1.1)
react-style-singleton: 2.2.3(@types/react@19.1.12)(react@19.1.1)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@19.1.12)(react@19.1.1)
use-sidecar: 1.1.3(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
react-style-singleton@2.2.3(@types/react@19.1.12)(react@19.1.1):
dependencies:
get-nonce: 1.0.1
react: 19.1.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.1.12
react-syntax-highlighter@15.6.6(react@19.1.1):
dependencies:
'@babel/runtime': 7.28.3
@@ -3845,6 +4018,21 @@ snapshots:
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
use-callback-ref@1.3.3(@types/react@19.1.12)(react@19.1.1):
dependencies:
react: 19.1.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.1.12
use-sidecar@1.1.3(@types/react@19.1.12)(react@19.1.1):
dependencies:
detect-node-es: 1.1.0
react: 19.1.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.1.12
use-sync-external-store@1.5.0(react@19.1.1):
dependencies:
react: 19.1.1

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

View File

@@ -0,0 +1,143 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -20,7 +20,7 @@ export const getProjects = async (): Promise<{ projects: Project[] }> => {
claudeProjectPath: fullPath,
meta: await getProjectMeta(fullPath),
};
})
}),
);
return {

View File

@@ -1,12 +1,13 @@
import { statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import type { Conversation } from "../../../lib/conversation-schema";
import { type ParsedCommand, parseCommandXml } from "../parseCommandXml";
import { parseJsonl } from "../parseJsonl";
import type { SessionMeta } from "../types";
const firstCommandCache = new Map<string, ParsedCommand | null>();
const ignoreCommands = ["/clear", "/login", "/logout", "/exit"];
const getFirstCommand = (
jsonlFilePath: string,
lines: string[],
@@ -16,7 +17,7 @@ const getFirstCommand = (
return cached;
}
let firstUserMessage: Conversation | null = null;
let firstCommand: ParsedCommand | null = null;
for (const line of lines) {
const conversation = parseJsonl(line).at(0);
@@ -25,27 +26,49 @@ const getFirstCommand = (
continue;
}
firstUserMessage = conversation;
const firstUserText =
conversation === null
? null
: typeof conversation.message.content === "string"
? conversation.message.content
: (() => {
const firstContent = conversation.message.content.at(0);
if (firstContent === undefined) return null;
if (typeof firstContent === "string") return firstContent;
if (firstContent.type === "text") return firstContent.text;
return null;
})();
if (firstUserText === null) {
continue;
}
if (
firstUserText ===
"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."
) {
continue;
}
const command = parseCommandXml(firstUserText);
if (
command.kind === "local-command-1" ||
command.kind === "local-command-2"
) {
continue;
}
if (
command.kind === "command" &&
ignoreCommands.includes(command.commandName)
) {
continue;
}
firstCommand = command;
break;
}
const firstMessageText =
firstUserMessage === null
? null
: typeof firstUserMessage.message.content === "string"
? firstUserMessage.message.content
: (() => {
const firstContent = firstUserMessage.message.content.at(0);
if (firstContent === undefined) return null;
if (typeof firstContent === "string") return firstContent;
if (firstContent.type === "text") return firstContent.text;
return null;
})();
const firstCommand =
firstMessageText === null ? null : parseCommandXml(firstMessageText);
if (firstCommand !== null) {
firstCommandCache.set(jsonlFilePath, firstCommand);
}

View File

@@ -6,7 +6,7 @@ import type { Session } from "../types";
import { getSessionMeta } from "./getSessionMeta";
export const getSessions = async (
projectId: string
projectId: string,
): Promise<{ sessions: Session[] }> => {
const claudeProjectPath = decodeProjectId(projectId);
@@ -22,7 +22,7 @@ export const getSessions = async (
jsonlFilePath: fullPath,
meta: await getSessionMeta(fullPath),
};
})
}),
);
return {