From 9144f260845f752c418c6aa56f090bc726d370da Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Sun, 19 Oct 2025 18:00:27 +0900 Subject: [PATCH] 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. --- .../conversationList/ConversationItem.tsx | 7 ++ .../conversationList/ConversationList.tsx | 9 ++- ...FileHistorySnapshotConversationContent.tsx | 68 +++++++++++++++++++ .../SidechainConversationModal.tsx | 15 ++-- .../[sessionId]/hooks/useSidechain.ts | 43 ++++++------ .../entry/AssistantEntrySchema.ts | 2 + .../entry/FileHIstorySnapshotEntrySchema.ts | 19 ++++++ .../entry/SummaryEntrySchema.ts | 2 + .../entry/SystemEntrySchema.ts | 2 + src/lib/conversation-schema/index.ts | 12 +++- .../presentation/ClaudeCodeController.ts | 2 +- .../project/services/ProjectMetaService.ts | 3 +- 12 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/FileHistorySnapshotConversationContent.tsx create mode 100644 src/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema.ts diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationItem.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationItem.tsx index 7f7033b..d6fc6e9 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationItem.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationItem.tsx @@ -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 ( + + ); + } + // sidechain = サブタスクのこと if (conversation.isSidechain) { // Root 以外はモーダルで中身を表示するのでここでは描画しない diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationList.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationList.tsx index 2fd3a44..1b47703 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationList.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/ConversationList.tsx @@ -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 = ({ ); const isSidechain = - conversation.type !== "summary" && conversation.isSidechain; + conversation.type !== "summary" && + conversation.type !== "file-history-snapshot" && + conversation.isSidechain; return [
  • = ({ conversation }) => { + const fileCount = Object.keys( + conversation.snapshot.trackedFileBackups, + ).length; + + return ( + + +
    +

    + File History Snapshot {fileCount > 0 && `(${fileCount} files)`} +

    + +
    +
    + +
    +
    +
    + Timestamp: + + {new Date(conversation.snapshot.timestamp).toLocaleString()} + +
    +
    + Message ID: + {conversation.messageId} +
    +
    + + Is Snapshot Update:{" "} + + {conversation.isSnapshotUpdate ? "Yes" : "No"} +
    + {fileCount > 0 && ( +
    +
    Tracked Files:
    +
      + {Object.keys(conversation.snapshot.trackedFileBackups).map( + (filePath) => ( +
    • + {filePath} +
    • + ), + )} +
    +
    + )} +
    +
    +
    +
    + ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationModal/SidechainConversationModal.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationModal/SidechainConversationModal.tsx index 12b526a..6c916bb 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationModal/SidechainConversationModal.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/conversationModal/SidechainConversationModal.tsx @@ -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 ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSidechain.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSidechain.ts index 37e5ce7..5df1a5a 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSidechain.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSidechain.ts @@ -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( - conversations - .filter((conv) => conv.type !== "summary") - .map((conv) => [conv.uuid, conv] as const), + return new Map( + 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(); + const groups = new Map(); 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; } diff --git a/src/lib/conversation-schema/entry/AssistantEntrySchema.ts b/src/lib/conversation-schema/entry/AssistantEntrySchema.ts index 78f4606..b388577 100644 --- a/src/lib/conversation-schema/entry/AssistantEntrySchema.ts +++ b/src/lib/conversation-schema/entry/AssistantEntrySchema.ts @@ -13,3 +13,5 @@ export const AssistantEntrySchema = BaseEntrySchema.extend({ requestId: z.string().optional(), isApiErrorMessage: z.boolean().optional(), }); + +export type AssistantEntry = z.infer; diff --git a/src/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema.ts b/src/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema.ts new file mode 100644 index 0000000..400273f --- /dev/null +++ b/src/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema.ts @@ -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 +>; diff --git a/src/lib/conversation-schema/entry/SummaryEntrySchema.ts b/src/lib/conversation-schema/entry/SummaryEntrySchema.ts index 8192940..711282a 100644 --- a/src/lib/conversation-schema/entry/SummaryEntrySchema.ts +++ b/src/lib/conversation-schema/entry/SummaryEntrySchema.ts @@ -5,3 +5,5 @@ export const SummaryEntrySchema = z.object({ summary: z.string(), leafUuid: z.string().uuid(), }); + +export type SummaryEntry = z.infer; diff --git a/src/lib/conversation-schema/entry/SystemEntrySchema.ts b/src/lib/conversation-schema/entry/SystemEntrySchema.ts index 47fab7c..fdb5305 100644 --- a/src/lib/conversation-schema/entry/SystemEntrySchema.ts +++ b/src/lib/conversation-schema/entry/SystemEntrySchema.ts @@ -10,3 +10,5 @@ export const SystemEntrySchema = BaseEntrySchema.extend({ toolUseID: z.string(), level: z.enum(["info"]), }); + +export type SystemEntry = z.infer; diff --git a/src/lib/conversation-schema/index.ts b/src/lib/conversation-schema/index.ts index 9224a8c..13929d4 100644 --- a/src/lib/conversation-schema/index.ts +++ b/src/lib/conversation-schema/index.ts @@ -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; +export type SidechainConversation = UserEntry | AssistantEntry | SystemEntry; diff --git a/src/server/core/claude-code/presentation/ClaudeCodeController.ts b/src/server/core/claude-code/presentation/ClaudeCodeController.ts index 0239c4a..56c752f 100644 --- a/src/server/core/claude-code/presentation/ClaudeCodeController.ts +++ b/src/server/core/claude-code/presentation/ClaudeCodeController.ts @@ -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; diff --git a/src/server/core/project/services/ProjectMetaService.ts b/src/server/core/project/services/ProjectMetaService.ts index 921cb3a..e643323 100644 --- a/src/server/core/project/services/ProjectMetaService.ts +++ b/src/server/core/project/services/ProjectMetaService.ts @@ -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; }