mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-25 01:04:21 +01:00
refactor: firstCommand
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { TaskExecutor } from "../utils/TaskExecutor";
|
||||
import { errorPagesCapture } from "./error-pages";
|
||||
import { homeCapture } from "./home";
|
||||
import { projectDetailCapture } from "./project-detail";
|
||||
import { projectsCapture } from "./projects";
|
||||
import { sessionDetailCapture } from "./session-detail";
|
||||
|
||||
@@ -17,7 +16,6 @@ const tasks = [
|
||||
...homeCapture.tasks,
|
||||
...errorPagesCapture.tasks,
|
||||
...projectsCapture.tasks,
|
||||
...projectDetailCapture.tasks,
|
||||
...sessionDetailCapture.tasks,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { projectIds } from "../config";
|
||||
import { defineCapture } from "../utils/defineCapture";
|
||||
|
||||
export const projectDetailCapture = defineCapture({
|
||||
href: `projects/${projectIds.sampleProject}`,
|
||||
cases: [
|
||||
{
|
||||
name: "filters-expanded",
|
||||
setup: async (page) => {
|
||||
const filterToggle = page.locator(
|
||||
'[data-testid="expand-filter-settings-button"]',
|
||||
);
|
||||
if (await filterToggle.isVisible()) {
|
||||
await filterToggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
} else {
|
||||
throw new Error("Filter settings button not found");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new-chat-modal",
|
||||
setup: async (page) => {
|
||||
const newChatButton = page.locator('[data-testid="new-chat"]');
|
||||
if (await newChatButton.isVisible()) {
|
||||
await newChatButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedCommand } from "../../../../server/core/claude-code/functions/parseCommandXml";
|
||||
import type { ParsedUserMessage } from "../../../../server/core/claude-code/functions/parseUserMessage";
|
||||
|
||||
export const firstCommandToTitle = (firstCommand: ParsedCommand) => {
|
||||
export const firstUserMessageToTitle = (firstCommand: ParsedUserMessage) => {
|
||||
switch (firstCommand.kind) {
|
||||
case "command":
|
||||
if (firstCommand.commandArgs === undefined) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTaskNotifications } from "@/hooks/useTaskNotifications";
|
||||
import { Badge } from "../../../../../../components/ui/badge";
|
||||
import { honoClient } from "../../../../../../lib/api/client";
|
||||
import { useProject } from "../../../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useSessionProcess } from "../hooks/useSessionProcess";
|
||||
import { ConversationList } from "./conversationList/ConversationList";
|
||||
@@ -117,8 +117,8 @@ export const SessionPageContent: FC<{
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
|
||||
{session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
{session.meta.firstUserMessage !== null
|
||||
? firstUserMessageToTitle(session.meta.firstUserMessage)
|
||||
: sessionId}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { FC } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { parseCommandXml } from "../../../../../../../server/core/claude-code/functions/parseCommandXml";
|
||||
import { parseUserMessage } from "../../../../../../../server/core/claude-code/functions/parseUserMessage";
|
||||
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
|
||||
|
||||
export const UserTextContent: FC<{ text: string; id?: string }> = ({
|
||||
text,
|
||||
id,
|
||||
}) => {
|
||||
const parsed = parseCommandXml(text);
|
||||
const parsed = parseUserMessage(text);
|
||||
|
||||
if (parsed.kind === "command") {
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Session } from "../../../../../../../server/core/types";
|
||||
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
|
||||
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
|
||||
|
||||
export const SessionsTab: FC<{
|
||||
@@ -86,8 +86,8 @@ export const SessionsTab: FC<{
|
||||
{sortedSessions.map((session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
const title =
|
||||
session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
session.meta.firstUserMessage !== null
|
||||
? firstUserMessageToTitle(session.meta.firstUserMessage)
|
||||
: session.id;
|
||||
|
||||
const sessionProcess = sessionProcesses.find(
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import path from "node:path";
|
||||
import { Path } from "@effect/platform";
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeClaudeProjectFilePath } from "./computeClaudeProjectFilePath";
|
||||
|
||||
describe("computeClaudeProjectFilePath", () => {
|
||||
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
|
||||
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
|
||||
|
||||
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
|
||||
const { computeClaudeProjectFilePath } = await import(
|
||||
"./computeClaudeProjectFilePath"
|
||||
);
|
||||
|
||||
const projectPath = "/home/me/dev/example";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
});
|
||||
const result = await Effect.runPromise(
|
||||
computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
}).pipe(Effect.provide(Path.layer)),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("末尾にスラッシュがある場合も正しく処理される", async () => {
|
||||
const { computeClaudeProjectFilePath } = await import(
|
||||
"./computeClaudeProjectFilePath"
|
||||
);
|
||||
|
||||
const projectPath = "/home/me/dev/example/";
|
||||
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
|
||||
|
||||
const result = computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
});
|
||||
const result = await Effect.runPromise(
|
||||
computeClaudeProjectFilePath({
|
||||
projectPath,
|
||||
claudeProjectsDirPath: TEST_PROJECTS_DIR,
|
||||
}).pipe(Effect.provide(Path.layer)),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConversationSchema } from "../../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "../../types";
|
||||
import type { ErrorJsonl, ExtendedConversation } from "../../types";
|
||||
|
||||
export const parseJsonl = (content: string) => {
|
||||
export const parseJsonl = (content: string): ExtendedConversation[] => {
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { parseCommandXml } from "./parseCommandXml";
|
||||
import { parseUserMessage } from "./parseUserMessage";
|
||||
|
||||
describe("parseCommandXml", () => {
|
||||
describe("command parsing", () => {
|
||||
it("parses command-name only", () => {
|
||||
const input = "<command-name>git status</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -17,7 +17,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses command-name with command-args", () => {
|
||||
const input =
|
||||
"<command-name>git commit</command-name><command-args>-m 'test'</command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -30,7 +30,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses command-name with command-message", () => {
|
||||
const input =
|
||||
"<command-name>ls</command-name><command-message>Listing files</command-message>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -43,7 +43,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses all command tags together", () => {
|
||||
const input =
|
||||
"<command-name>npm install</command-name><command-args>--save-dev vitest</command-args><command-message>Installing test dependencies</command-message>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -56,7 +56,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses command tags with whitespace in content", () => {
|
||||
const input =
|
||||
"<command-name>\n git status \n</command-name><command-args> --short </command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -69,7 +69,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses command tags in different order", () => {
|
||||
const input =
|
||||
"<command-message>Test message</command-message><command-args>-v</command-args><command-name>test command</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -83,7 +83,7 @@ describe("parseCommandXml", () => {
|
||||
describe("local-command parsing", () => {
|
||||
it("parses local-command-stdout", () => {
|
||||
const input = "<local-command-stdout>output text</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
@@ -94,7 +94,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses local-command-stdout with multiline content", () => {
|
||||
const input =
|
||||
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "local-command",
|
||||
@@ -105,7 +105,7 @@ describe("parseCommandXml", () => {
|
||||
it("parses local-command-stdout with whitespace", () => {
|
||||
const input =
|
||||
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// The regex pattern preserves all whitespace in content
|
||||
expect(result).toEqual({
|
||||
@@ -119,7 +119,7 @@ describe("parseCommandXml", () => {
|
||||
it("returns command when both command and local-command tags exist", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name><local-command-stdout>output</local-command-stdout>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
@@ -131,7 +131,7 @@ describe("parseCommandXml", () => {
|
||||
describe("fallback to text", () => {
|
||||
it("returns text when no matching tags found", () => {
|
||||
const input = "just plain text";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
@@ -141,7 +141,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("returns text when tags are not closed properly", () => {
|
||||
const input = "<command-name>incomplete";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
@@ -151,7 +151,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("returns text when tags are mismatched", () => {
|
||||
const input = "<command-name>test</different-tag>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
@@ -161,7 +161,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("returns text with empty string", () => {
|
||||
const input = "";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
@@ -171,7 +171,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("returns text with only unrecognized tags", () => {
|
||||
const input = "<unknown-tag>content</unknown-tag>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "text",
|
||||
@@ -184,7 +184,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles multiple same tags (uses first match)", () => {
|
||||
const input =
|
||||
"<command-name>first</command-name><command-name>second</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
@@ -194,7 +194,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("handles empty tag content", () => {
|
||||
const input = "<command-name></command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -207,7 +207,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles tags with special characters in content", () => {
|
||||
const input =
|
||||
"<command-name>git commit -m 'test & demo'</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
@@ -217,7 +217,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("does not match nested tags (regex limitation)", () => {
|
||||
const input = "<command-name><nested>inner</nested>outer</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// The regex won't match properly nested tags due to [^<]* pattern
|
||||
expect(result.kind).toBe("text");
|
||||
@@ -226,7 +226,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles tags with surrounding text", () => {
|
||||
const input =
|
||||
"Some text before <command-name>test</command-name> and after";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -239,7 +239,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles newlines between tags", () => {
|
||||
const input =
|
||||
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -252,7 +252,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles very long content", () => {
|
||||
const longContent = "x".repeat(10000);
|
||||
const input = `<command-name>${longContent}</command-name>`;
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
@@ -262,7 +262,7 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("handles tags with attributes (not matched)", () => {
|
||||
const input = '<command-name attr="value">test</command-name>';
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
|
||||
expect(result.kind).toBe("text");
|
||||
@@ -270,14 +270,14 @@ describe("parseCommandXml", () => {
|
||||
|
||||
it("handles self-closing tags (not matched)", () => {
|
||||
const input = "<command-name />";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("handles Unicode content", () => {
|
||||
const input = "<command-name>テスト コマンド 🚀</command-name>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "command",
|
||||
@@ -290,7 +290,7 @@ describe("parseCommandXml", () => {
|
||||
it("handles mixed content with multiple tag types", () => {
|
||||
const input =
|
||||
"Some text <command-name>cmd</command-name> more text <unknown>tag</unknown>";
|
||||
const result = parseCommandXml(input);
|
||||
const result = parseUserMessage(input);
|
||||
|
||||
expect(result.kind).toBe("command");
|
||||
if (result.kind === "command") {
|
||||
@@ -7,7 +7,7 @@ const matchSchema = z.object({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const parsedCommandSchema = z.union([
|
||||
export const parsedUserMessageSchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal("command"),
|
||||
commandName: z.string(),
|
||||
@@ -24,9 +24,9 @@ export const parsedCommandSchema = z.union([
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ParsedCommand = z.infer<typeof parsedCommandSchema>;
|
||||
export type ParsedUserMessage = z.infer<typeof parsedUserMessageSchema>;
|
||||
|
||||
export const parseCommandXml = (content: string): ParsedCommand => {
|
||||
export const parseUserMessage = (content: string): ParsedUserMessage => {
|
||||
const matches = Array.from(content.matchAll(regExp))
|
||||
.map((match) => matchSchema.safeParse(match.groups))
|
||||
.filter((result) => result.success)
|
||||
@@ -41,7 +41,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
// Filter sessions based on hideNoUserMessageSession setting
|
||||
if (userConfig.hideNoUserMessageSession) {
|
||||
filteredSessions = filteredSessions.filter((session) => {
|
||||
return session.meta.firstCommand !== null;
|
||||
return session.meta.firstUserMessage !== null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ const LayerImpl = Effect.gen(function* () {
|
||||
for (const session of filteredSessions) {
|
||||
// Generate title for comparison
|
||||
const title =
|
||||
session.meta.firstCommand !== null
|
||||
session.meta.firstUserMessage !== null
|
||||
? (() => {
|
||||
const cmd = session.meta.firstCommand;
|
||||
const cmd = session.meta.firstUserMessage;
|
||||
switch (cmd.kind) {
|
||||
case "command":
|
||||
return cmd.commandArgs === undefined
|
||||
|
||||
22
src/server/core/session/functions/extractFirstUserText.ts
Normal file
22
src/server/core/session/functions/extractFirstUserText.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ExtendedConversation } from "../../types";
|
||||
|
||||
export const extractFirstUserText = (
|
||||
conversation: ExtendedConversation,
|
||||
): string | null => {
|
||||
if (conversation.type !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstUserText =
|
||||
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;
|
||||
})();
|
||||
|
||||
return firstUserText;
|
||||
};
|
||||
58
src/server/core/session/functions/isValidFirstMessage.ts
Normal file
58
src/server/core/session/functions/isValidFirstMessage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
type ParsedUserMessage,
|
||||
parseUserMessage,
|
||||
} from "../../claude-code/functions/parseUserMessage";
|
||||
import type { ExtendedConversation } from "../../types";
|
||||
import { extractFirstUserText } from "./extractFirstUserText";
|
||||
|
||||
const ignoreCommands = [
|
||||
"/clear",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/exit",
|
||||
"/mcp",
|
||||
"/memory",
|
||||
];
|
||||
|
||||
export const extractFirstUserMessage = (
|
||||
conversation: ExtendedConversation,
|
||||
): ParsedUserMessage | undefined => {
|
||||
if (conversation.type !== "user") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (conversation.isSidechain === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstUserText = extractFirstUserText(conversation);
|
||||
|
||||
if (firstUserText === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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."
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (firstUserText === "Warmup") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const command = parseUserMessage(firstUserText);
|
||||
if (command.kind === "local-command") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
command.kind === "command" &&
|
||||
ignoreCommands.includes(command.commandName)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return command;
|
||||
};
|
||||
@@ -116,7 +116,7 @@ describe("SessionRepository", () => {
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 3,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
};
|
||||
|
||||
const mockContent = `{"type":"user","message":{"role":"user","content":"Hello"}}\n{"type":"assistant","message":{"role":"assistant","content":"Hi"}}\n{"type":"user","message":{"role":"user","content":"Test"}}`;
|
||||
@@ -198,7 +198,7 @@ describe("SessionRepository", () => {
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = Layer.succeed(
|
||||
VirtualConversationDatabase,
|
||||
@@ -254,7 +254,7 @@ describe("SessionRepository", () => {
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
@@ -291,7 +291,7 @@ describe("SessionRepository", () => {
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
@@ -326,7 +326,7 @@ describe("SessionRepository", () => {
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
@@ -381,7 +381,7 @@ describe("SessionRepository", () => {
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
@@ -430,7 +430,7 @@ describe("SessionRepository", () => {
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
@@ -495,7 +495,7 @@ describe("SessionRepository", () => {
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
@@ -528,7 +528,7 @@ describe("SessionRepository", () => {
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
};
|
||||
|
||||
const mockConversations: (Conversation | ErrorJsonl)[] = [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Option } from "effect";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { parseCommandXml } from "../../claude-code/functions/parseCommandXml";
|
||||
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
|
||||
import { parseUserMessage } from "../../claude-code/functions/parseUserMessage";
|
||||
import { decodeProjectId } from "../../project/functions/id";
|
||||
import type { Session, SessionDetail } from "../../types";
|
||||
import { decodeSessionId, encodeSessionId } from "../functions/id";
|
||||
@@ -101,7 +101,7 @@ const LayerImpl = Effect.gen(function* () {
|
||||
jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`,
|
||||
meta: {
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
},
|
||||
conversations: virtualConversation.conversations,
|
||||
lastModifiedAt:
|
||||
@@ -261,8 +261,8 @@ const LayerImpl = Effect.gen(function* () {
|
||||
last !== undefined ? new Date(last.timestamp) : new Date(),
|
||||
meta: {
|
||||
messageCount: conversations.length,
|
||||
firstCommand: firstUserText
|
||||
? parseCommandXml(firstUserText)
|
||||
firstUserMessage: firstUserText
|
||||
? parseUserMessage(firstUserText)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { parsedCommandSchema } from "../claude-code/functions/parseCommandXml";
|
||||
import { parsedUserMessageSchema } from "../claude-code/functions/parseUserMessage";
|
||||
|
||||
export const sessionMetaSchema = z.object({
|
||||
messageCount: z.number(),
|
||||
firstCommand: parsedCommandSchema.nullable(),
|
||||
firstUserMessage: parsedUserMessageSchema.nullable(),
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("SessionMetaService", () => {
|
||||
);
|
||||
|
||||
expect(result.messageCount).toBe(1);
|
||||
expect(result.firstCommand).toEqual({
|
||||
expect(result.firstUserMessage).toEqual({
|
||||
kind: "text",
|
||||
content: "test message",
|
||||
});
|
||||
@@ -153,7 +153,7 @@ describe("SessionMetaService", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.firstCommand).toEqual({
|
||||
expect(result.firstUserMessage).toEqual({
|
||||
kind: "command",
|
||||
commandName: "/test",
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("SessionMetaService", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.firstCommand).toEqual({
|
||||
expect(result.firstUserMessage).toEqual({
|
||||
kind: "text",
|
||||
content: "actual message",
|
||||
});
|
||||
|
||||
@@ -5,25 +5,16 @@ import {
|
||||
makeFileCacheStorageLayer,
|
||||
} from "../../../lib/storage/FileCacheStorage";
|
||||
import { PersistentService } from "../../../lib/storage/FileCacheStorage/PersistentService";
|
||||
import {
|
||||
type ParsedCommand,
|
||||
parseCommandXml,
|
||||
parsedCommandSchema,
|
||||
} from "../../claude-code/functions/parseCommandXml";
|
||||
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
|
||||
import {
|
||||
type ParsedUserMessage,
|
||||
parsedUserMessageSchema,
|
||||
} from "../../claude-code/functions/parseUserMessage";
|
||||
import type { SessionMeta } from "../../types";
|
||||
import { decodeSessionId } from "../functions/id";
|
||||
import { extractFirstUserMessage } from "../functions/isValidFirstMessage";
|
||||
|
||||
const ignoreCommands = [
|
||||
"/clear",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/exit",
|
||||
"/mcp",
|
||||
"/memory",
|
||||
];
|
||||
|
||||
const parsedCommandOrNullSchema = parsedCommandSchema.nullable();
|
||||
const parsedUserMessageOrNullSchema = parsedUserMessageSchema.nullable();
|
||||
|
||||
export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
SessionMetaService,
|
||||
@@ -42,43 +33,23 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const firstCommandCache = yield* FileCacheStorage<ParsedCommand | null>();
|
||||
const firstCommandCache =
|
||||
yield* FileCacheStorage<ParsedUserMessage | null>();
|
||||
const sessionMetaCacheRef = yield* Ref.make(
|
||||
new Map<string, SessionMeta>(),
|
||||
);
|
||||
|
||||
const extractFirstUserText = (
|
||||
conversation: Exclude<ReturnType<typeof parseJsonl>[0], undefined>,
|
||||
): string | null => {
|
||||
if (conversation.type !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstUserText =
|
||||
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;
|
||||
})();
|
||||
|
||||
return firstUserText;
|
||||
};
|
||||
|
||||
const getFirstCommand = (
|
||||
const getFirstUserMessage = (
|
||||
jsonlFilePath: string,
|
||||
lines: string[],
|
||||
): Effect.Effect<ParsedCommand | null, Error> =>
|
||||
): Effect.Effect<ParsedUserMessage | null, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* firstCommandCache.get(jsonlFilePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let firstCommand: ParsedCommand | null = null;
|
||||
let firstUserMessage: ParsedUserMessage | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
@@ -87,40 +58,22 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstUserText = extractFirstUserText(conversation);
|
||||
const maybeFirstUserMessage = extractFirstUserMessage(conversation);
|
||||
|
||||
if (firstUserText === null) {
|
||||
if (maybeFirstUserMessage === undefined) {
|
||||
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;
|
||||
}
|
||||
firstUserMessage = maybeFirstUserMessage;
|
||||
|
||||
const command = parseCommandXml(firstUserText);
|
||||
if (command.kind === "local-command") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
command.kind === "command" &&
|
||||
ignoreCommands.includes(command.commandName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
firstCommand = command;
|
||||
break;
|
||||
}
|
||||
|
||||
if (firstCommand !== null) {
|
||||
yield* firstCommandCache.set(jsonlFilePath, firstCommand);
|
||||
if (firstUserMessage !== null) {
|
||||
yield* firstCommandCache.set(jsonlFilePath, firstUserMessage);
|
||||
}
|
||||
|
||||
return firstCommand;
|
||||
return firstUserMessage;
|
||||
});
|
||||
|
||||
const getSessionMeta = (
|
||||
@@ -138,11 +91,14 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
const content = yield* fs.readFileString(sessionPath);
|
||||
const lines = content.split("\n");
|
||||
|
||||
const firstCommand = yield* getFirstCommand(sessionPath, lines);
|
||||
const firstUserMessage = yield* getFirstUserMessage(
|
||||
sessionPath,
|
||||
lines,
|
||||
);
|
||||
|
||||
const sessionMeta: SessionMeta = {
|
||||
messageCount: lines.length,
|
||||
firstCommand,
|
||||
firstUserMessage: firstUserMessage,
|
||||
};
|
||||
|
||||
yield* Ref.update(sessionMetaCacheRef, (cache) => {
|
||||
@@ -172,8 +128,8 @@ export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
makeFileCacheStorageLayer(
|
||||
"first-command-cache",
|
||||
parsedCommandOrNullSchema,
|
||||
"first-user-message-cache",
|
||||
parsedUserMessageOrNullSchema,
|
||||
),
|
||||
),
|
||||
Layer.provide(PersistentService.Live),
|
||||
|
||||
@@ -27,6 +27,8 @@ export type ErrorJsonl = {
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export type ExtendedConversation = Conversation | ErrorJsonl;
|
||||
|
||||
export type SessionDetail = Session & {
|
||||
conversations: (Conversation | ErrorJsonl)[];
|
||||
conversations: ExtendedConversation[];
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("InitializeService", () => {
|
||||
lastModifiedAt: Date;
|
||||
meta: {
|
||||
messageCount: number;
|
||||
firstCommand: {
|
||||
firstUserMessage: {
|
||||
kind: "command";
|
||||
commandName: string;
|
||||
commandArgs?: string;
|
||||
@@ -72,7 +72,7 @@ describe("InitializeService", () => {
|
||||
getSessionMeta: () =>
|
||||
Effect.succeed({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
}),
|
||||
invalidateSession: () => Effect.void,
|
||||
});
|
||||
@@ -134,7 +134,7 @@ describe("InitializeService", () => {
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 5,
|
||||
firstCommand: {
|
||||
firstUserMessage: {
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
},
|
||||
@@ -146,7 +146,7 @@ describe("InitializeService", () => {
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 3,
|
||||
firstCommand: null,
|
||||
firstUserMessage: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -318,7 +318,7 @@ describe("InitializeService", () => {
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 5,
|
||||
firstCommand: {
|
||||
firstUserMessage: {
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user