feat: add filter option only having user messages

This commit is contained in:
d-kimsuon
2025-08-30 15:16:53 +09:00
parent bd0aeb490b
commit efd06c720e
7 changed files with 195 additions and 19 deletions

View File

@@ -23,6 +23,7 @@
"@hono/auth-js": "^1.1.0",
"@hono/zod-validator": "^0.7.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -31,6 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.9.5",
"jotai": "^2.13.1",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"react": "^19.1.1",

73
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@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-checkbox':
specifier: ^1.3.3
version: 1.3.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-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)
@@ -44,6 +47,9 @@ importers:
hono:
specifier: ^4.9.5
version: 4.9.5
jotai:
specifier: ^2.13.1
version: 2.13.1(@types/react@19.1.12)(react@19.1.1)
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.1.1)
@@ -611,6 +617,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
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-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
@@ -827,6 +846,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
@@ -1403,6 +1431,24 @@ packages:
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
jotai@2.13.1:
resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
@@ -2456,6 +2502,22 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-checkbox@1.3.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)':
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-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-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-size': 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-collapsible@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)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2655,6 +2717,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-use-previous@1.1.1(@types/react@19.1.12)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-use-rect@1.1.1(@types/react@19.1.12)(react@19.1.1)':
dependencies:
'@radix-ui/rect': 1.1.1
@@ -3171,6 +3239,11 @@ snapshots:
jose@6.1.0: {}
jotai@2.13.1(@types/react@19.1.12)(react@19.1.1):
optionalDependencies:
'@types/react': 19.1.12
react: 19.1.1
js-tokens@9.0.1: {}
json-parse-even-better-errors@4.0.0: {}

View File

@@ -1,7 +1,9 @@
"use client";
import { useAtom } from "jotai";
import { ArrowLeftIcon, FolderIcon, MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { useId } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -10,14 +12,24 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { hideSessionsWithoutUserMessagesAtom } from "../store/filterAtoms";
import { pagesPath } from "../../../../lib/$path";
import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const checkboxId = useId();
const {
data: { project, sessions },
} = useProject(projectId);
const [hideSessionsWithoutUserMessages, setHideSessionsWithoutUserMessages] =
useAtom(hideSessionsWithoutUserMessagesAtom);
// Apply filtering
const filteredSessions = hideSessionsWithoutUserMessages
? sessions.filter((session) => session.meta.firstCommand !== null)
: sessions;
return (
<div className="container mx-auto px-4 py-8">
@@ -47,10 +59,36 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
<section>
<h2 className="text-xl font-semibold mb-4">
Conversation Sessions{" "}
{project.meta.sessionCount ? `(${project.meta.sessionCount})` : ""}
{filteredSessions.length > 0 ? `(${filteredSessions.length})` : ""}
{hideSessionsWithoutUserMessages &&
filteredSessions.length !== sessions.length && (
<span className="text-sm text-muted-foreground ml-2">
of {sessions.length} total
</span>
)}
</h2>
{sessions.length === 0 ? (
{/* Filter Controls */}
<div className="mb-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-2">
<Checkbox
id={checkboxId}
checked={hideSessionsWithoutUserMessages}
onCheckedChange={setHideSessionsWithoutUserMessages}
/>
<label
htmlFor={checkboxId}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Hide sessions without user messages
</label>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">
Only show sessions that contain user commands or messages
</p>
</div>
{filteredSessions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
@@ -64,7 +102,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
</Card>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
{sessions.map((session) => (
{filteredSessions.map((session) => (
<Card
key={session.id}
className="hover:shadow-md transition-shadow"
@@ -89,7 +127,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
Last modified:{" "}
{session.meta.lastModifiedAt
? new Date(
session.meta.lastModifiedAt,
session.meta.lastModifiedAt
).toLocaleDateString()
: ""}
</p>
@@ -101,7 +139,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
<Button asChild className="w-full">
<Link
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id,
session.id
)}`}
>
View Session

View File

@@ -35,9 +35,9 @@ export const ConversationItem: FC<{
);
}
// sidechain = サブタスクのこと
if (conversation.isSidechain) {
// sidechain = サブタスクのこと
// 別途ツール呼び出しの方で描画可能にするのでここでは表示しない
// Root 以外はモーダルで中身を表示するのでここでは描画しない
if (!isRootSidechain(conversation)) {
return null;
}
@@ -46,7 +46,7 @@ export const ConversationItem: FC<{
<SidechainConversationModal
conversation={conversation}
sidechainConversations={getSidechainConversations(
conversation.uuid,
conversation.uuid
).map((original) => {
if (original.type === "summary") return original;
return {

View File

@@ -0,0 +1,24 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export type SessionFilterOptions = {
hideSessionsWithoutUserMessages: boolean;
};
export const sessionFilterAtom = atomWithStorage<SessionFilterOptions>(
"session-filters",
{
hideSessionsWithoutUserMessages: true,
}
);
export const hideSessionsWithoutUserMessagesAtom = atom(
(get) => get(sessionFilterAtom).hideSessionsWithoutUserMessages,
(get, set, newValue: boolean) => {
const currentFilters = get(sessionFilterAtom);
set(sessionFilterAtom, {
...currentFilters,
hideSessionsWithoutUserMessages: newValue,
});
}
);

View File

@@ -0,0 +1,32 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -6,11 +6,18 @@ import type { SessionMeta } from "../types";
const firstCommandCache = new Map<string, ParsedCommand | null>();
const ignoreCommands = ["/clear", "/login", "/logout", "/exit"];
const ignoreCommands = [
"/clear",
"/login",
"/logout",
"/exit",
"/mcp",
"/memory",
];
const getFirstCommand = (
jsonlFilePath: string,
lines: string[],
lines: string[]
): ParsedCommand | null => {
const cached = firstCommandCache.get(jsonlFilePath);
if (cached !== undefined) {
@@ -30,14 +37,14 @@ const getFirstCommand = (
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;
})();
? 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;
@@ -77,7 +84,7 @@ const getFirstCommand = (
};
export const getSessionMeta = async (
jsonlFilePath: string,
jsonlFilePath: string
): Promise<SessionMeta> => {
const stats = statSync(jsonlFilePath);
const lastModifiedUnixTime = stats.ctime.getTime();