feat: add support for file history snapshots in conversation components

- Introduced a new `FileHistorySnapshotConversationContent` component to handle rendering of file history snapshots.
- Updated `ConversationItem` and `ConversationList` to accommodate the new conversation type.
- Modified the conversation schema to include `FileHistorySnapshotEntrySchema` and related types.
- Enhanced the `useSidechain` hook to filter out file history snapshots from sidechain conversations.
- Adjusted the `SidechainConversationModal` to correctly handle the new conversation type.
This commit is contained in:
d-kimsuon
2025-10-19 18:00:27 +09:00
parent da2d45165a
commit 9144f26084
12 changed files with 151 additions and 33 deletions

View File

@@ -3,6 +3,7 @@ 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 { FileHistorySnapshotConversationContent } from "./FileHistorySnapshotConversationContent";
import { MetaConversationContent } from "./MetaConversationContent";
import { SummaryConversationContent } from "./SummaryConversationContent";
import { SystemConversationContent } from "./SystemConversationContent";
@@ -35,6 +36,12 @@ export const ConversationItem: FC<{
);
}
if (conversation.type === "file-history-snapshot") {
return (
<FileHistorySnapshotConversationContent conversation={conversation} />
);
}
// sidechain = サブタスクのこと
if (conversation.isSidechain) {
// Root 以外はモーダルで中身を表示するのでここでは描画しない

View File

@@ -31,6 +31,11 @@ const getConversationKey = (conversation: Conversation) => {
return `summary_${conversation.leafUuid}`;
}
if (conversation.type === "file-history-snapshot") {
return `file-history-snapshot_${conversation.messageId}`;
}
conversation satisfies never;
throw new Error(`Unknown conversation type: ${conversation}`);
};
@@ -132,7 +137,9 @@ export const ConversationList: FC<ConversationListProps> = ({
);
const isSidechain =
conversation.type !== "summary" && conversation.isSidechain;
conversation.type !== "summary" &&
conversation.type !== "file-history-snapshot" &&
conversation.isSidechain;
return [
<li

View File

@@ -0,0 +1,68 @@
import { ChevronDown } from "lucide-react";
import type { FC } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { FileHistorySnapshotEntry } from "@/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema";
export const FileHistorySnapshotConversationContent: FC<{
conversation: FileHistorySnapshotEntry;
}> = ({ conversation }) => {
const fileCount = Object.keys(
conversation.snapshot.trackedFileBackups,
).length;
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">
File History Snapshot {fileCount > 0 && `(${fileCount} files)`}
</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">
<div className="space-y-2">
<div className="text-xs">
<span className="text-muted-foreground">Timestamp: </span>
<span>
{new Date(conversation.snapshot.timestamp).toLocaleString()}
</span>
</div>
<div className="text-xs">
<span className="text-muted-foreground">Message ID: </span>
<span className="font-mono">{conversation.messageId}</span>
</div>
<div className="text-xs">
<span className="text-muted-foreground">
Is Snapshot Update:{" "}
</span>
<span>{conversation.isSnapshotUpdate ? "Yes" : "No"}</span>
</div>
{fileCount > 0 && (
<div className="text-xs">
<div className="text-muted-foreground mb-1">Tracked Files:</div>
<ul className="list-disc list-inside space-y-1">
{Object.keys(conversation.snapshot.trackedFileBackups).map(
(filePath) => (
<li
key={filePath}
className="font-mono text-xs break-all"
>
{filePath}
</li>
),
)}
</ul>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -11,12 +11,15 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { Conversation } from "@/lib/conversation-schema";
import type {
Conversation,
SidechainConversation,
} from "@/lib/conversation-schema";
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
import { ConversationList } from "../conversationList/ConversationList";
type SidechainConversationModalProps = {
conversation: Conversation;
conversation: SidechainConversation;
sidechainConversations: Conversation[];
getToolResult: (toolUseId: string) => ToolResultContent | undefined;
};
@@ -25,7 +28,10 @@ const sidechainTitle = (conversations: Conversation[]): string => {
const firstConversation = conversations.at(0);
const defaultTitle = `${conversations.length} conversations (${
firstConversation?.type !== "summary" ? firstConversation?.uuid : ""
firstConversation?.type !== "summary" &&
firstConversation?.type !== "file-history-snapshot"
? firstConversation?.uuid
: ""
})`;
if (!firstConversation) {
@@ -57,8 +63,7 @@ export const SidechainConversationModal: FC<
> = ({ conversation, sidechainConversations, getToolResult }) => {
const title = sidechainTitle(sidechainConversations);
const rootUuid =
conversation.type !== "summary" ? conversation.uuid : conversation.leafUuid;
const rootUuid = conversation.uuid;
return (
<Dialog>

View File

@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react";
import type { Conversation } from "@/lib/conversation-schema";
import type {
Conversation,
SidechainConversation,
} from "@/lib/conversation-schema";
export const useSidechain = (conversations: Conversation[]) => {
const sidechainConversations = conversations.filter(
(conv) => conv.type !== "summary" && conv.type !== "file-history-snapshot",
);
const conversationMap = useMemo(() => {
return new Map<string, Conversation>(
conversations
.filter((conv) => conv.type !== "summary")
.map((conv) => [conv.uuid, conv] as const),
return new Map<string, SidechainConversation>(
sidechainConversations.map((conv) => [conv.uuid, conv] as const),
);
}, [conversations]);
}, [sidechainConversations]);
const getRootConversationRecursive = useCallback(
(conversation: Conversation): Conversation => {
if (conversation.type === "summary") {
return conversation;
}
(conversation: SidechainConversation): SidechainConversation => {
if (conversation.parentUuid === null) {
return conversation;
}
@@ -31,20 +32,15 @@ export const useSidechain = (conversations: Conversation[]) => {
);
const sidechainConversationGroups = useMemo(() => {
const filtered = conversations
.filter((conv) => conv.type !== "summary")
.filter((conv) => conv.isSidechain === true);
const filtered = sidechainConversations.filter(
(conv) => conv.isSidechain === true,
);
const groups = new Map<string, Conversation[]>();
const groups = new Map<string, SidechainConversation[]>();
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 {
@@ -53,11 +49,14 @@ export const useSidechain = (conversations: Conversation[]) => {
}
return groups;
}, [conversations, getRootConversationRecursive]);
}, [sidechainConversations, getRootConversationRecursive]);
const isRootSidechain = useCallback(
(conversation: Conversation) => {
if (conversation.type === "summary") {
if (
conversation.type === "summary" ||
conversation.type === "file-history-snapshot"
) {
return false;
}

View File

@@ -13,3 +13,5 @@ export const AssistantEntrySchema = BaseEntrySchema.extend({
requestId: z.string().optional(),
isApiErrorMessage: z.boolean().optional(),
});
export type AssistantEntry = z.infer<typeof AssistantEntrySchema>;

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
export const FileHistorySnapshotEntrySchema = z.object({
// discriminator
type: z.literal("file-history-snapshot"),
// required
messageId: z.string(),
snapshot: z.object({
messageId: z.string(),
trackedFileBackups: z.record(z.string(), z.unknown()),
timestamp: z.string(),
}),
isSnapshotUpdate: z.boolean(),
});
export type FileHistorySnapshotEntry = z.infer<
typeof FileHistorySnapshotEntrySchema
>;

View File

@@ -5,3 +5,5 @@ export const SummaryEntrySchema = z.object({
summary: z.string(),
leafUuid: z.string().uuid(),
});
export type SummaryEntry = z.infer<typeof SummaryEntrySchema>;

View File

@@ -10,3 +10,5 @@ export const SystemEntrySchema = BaseEntrySchema.extend({
toolUseID: z.string(),
level: z.enum(["info"]),
});
export type SystemEntry = z.infer<typeof SystemEntrySchema>;

View File

@@ -1,14 +1,20 @@
import { z } from "zod";
import { AssistantEntrySchema } from "./entry/AssistantEntrySchema";
import {
type AssistantEntry,
AssistantEntrySchema,
} from "./entry/AssistantEntrySchema";
import { FileHistorySnapshotEntrySchema } from "./entry/FileHIstorySnapshotEntrySchema";
import { SummaryEntrySchema } from "./entry/SummaryEntrySchema";
import { SystemEntrySchema } from "./entry/SystemEntrySchema";
import { UserEntrySchema } from "./entry/UserEntrySchema";
import { type SystemEntry, SystemEntrySchema } from "./entry/SystemEntrySchema";
import { type UserEntry, UserEntrySchema } from "./entry/UserEntrySchema";
export const ConversationSchema = z.union([
UserEntrySchema,
AssistantEntrySchema,
SummaryEntrySchema,
SystemEntrySchema,
FileHistorySnapshotEntrySchema,
]);
export type Conversation = z.infer<typeof ConversationSchema>;
export type SidechainConversation = UserEntry | AssistantEntry | SystemEntry;

View File

@@ -64,7 +64,7 @@ const LayerImpl = Effect.gen(function* () {
response: {
globalCommands: globalCommands,
projectCommands: projectCommands,
defaultCommands: ["init", "compact"],
defaultCommands: ["init", "compact", "security-review", "review"],
},
status: 200,
} as const satisfies ControllerResponse;

View File

@@ -39,7 +39,8 @@ const LayerImpl = Effect.gen(function* () {
if (
conversation === undefined ||
conversation.type === "summary" ||
conversation.type === "x-error"
conversation.type === "x-error" ||
conversation.type === "file-history-snapshot"
) {
continue;
}