diff --git a/apps/codex-claw/src/routes/api/sessions.ts b/apps/codex-claw/src/routes/api/sessions.ts index 3fd05e4..9ce2f5a 100644 --- a/apps/codex-claw/src/routes/api/sessions.ts +++ b/apps/codex-claw/src/routes/api/sessions.ts @@ -11,9 +11,19 @@ import { export const Route = createFileRoute('/api/sessions')({ server: { handlers: { - GET: async () => { + GET: async ({ request }) => { try { - return json(await listCodexSessions()) + const url = new URL(request.url) + return json( + await listCodexSessions({ + query: url.searchParams.get('q') ?? undefined, + filter: url.searchParams.get('filter') ?? undefined, + tag: url.searchParams.get('tag') ?? undefined, + includeArchived: + url.searchParams.get('includeArchived') === '1' || + url.searchParams.get('includeArchived') === 'true', + }), + ) } catch (err) { return json( { @@ -62,6 +72,11 @@ export const Route = createFileRoute('/api/sessions')({ typeof body.friendlyId === 'string' ? body.friendlyId.trim() : '' const label = typeof body.label === 'string' ? body.label.trim() : undefined + const tags = Array.isArray(body.tags) + ? body.tags.filter((tag): tag is string => typeof tag === 'string') + : undefined + const archived = + typeof body.archived === 'boolean' ? body.archived : undefined let sessionKey = rawSessionKey if (!sessionKey && rawFriendlyId) { @@ -76,7 +91,12 @@ export const Route = createFileRoute('/api/sessions')({ ) } - const payload = await patchCodexSession({ key: sessionKey, label }) + const payload = await patchCodexSession({ + key: sessionKey, + label, + tags, + archived, + }) return json({ ok: true, sessionKey: payload.key, diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index 29f209e..fcbf021 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -9,6 +9,7 @@ import type { RepoContextSelection, SessionListResponse, SessionMeta, + SessionSummary, TaskListResponse, WorkspaceListResponse, WorkspaceSummary, @@ -21,6 +22,15 @@ type GatewayStatusResponse = { export const chatQueryKeys = { sessions: ['chat', 'sessions'] as const, + sessionSearch: function sessionSearch(params: SessionSearchParams) { + return [ + 'chat', + 'session-search', + params.query ?? '', + params.filter ?? 'workspace', + params.tag ?? '', + ] as const + }, workspaces: ['chat', 'workspaces'] as const, tasks: ['chat', 'tasks'] as const, history: function history(friendlyId: string, sessionKey: string) { @@ -28,13 +38,66 @@ export const chatQueryKeys = { }, } as const +export type SessionSearchFilter = + | 'workspace' + | 'pinned' + | 'recent' + | 'failed' + | 'tagged' + | 'archived' + +export type SessionSearchParams = { + query?: string + filter?: SessionSearchFilter + tag?: string + signal?: AbortSignal +} + export async function fetchSessions(): Promise> { - const res = await fetch('/api/sessions') + const res = await fetch('/api/sessions?includeArchived=1') if (!res.ok) throw new Error(await readError(res)) const data = (await res.json()) as SessionListResponse return normalizeSessions(data.sessions) } +export async function fetchSessionSearch( + params: SessionSearchParams, +): Promise> { + const query = new URLSearchParams({ includeArchived: '1' }) + const rawQuery = params.query?.trim() ?? '' + const filter = params.filter ?? 'workspace' + if (rawQuery) query.set('q', rawQuery) + if (filter !== 'workspace' && filter !== 'pinned') { + query.set('filter', filter) + } + if (params.tag?.trim()) query.set('tag', params.tag.trim()) + const res = await fetch('/api/sessions?' + query.toString(), { + signal: params.signal, + }) + if (!res.ok) throw new Error(await readError(res)) + const data = (await res.json()) as SessionListResponse + return normalizeSessions(data.sessions) +} + +export async function updateSessionMetadata(payload: { + sessionKey: string + tags?: Array + archived?: boolean +}): Promise { + const res = await fetch('/api/sessions', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) throw new Error(await readError(res)) + const data = (await res.json()) as { entry?: SessionSummary } + if (!data.entry) { + throw new Error('Session metadata update returned no session.') + } + const [session] = normalizeSessions([data.entry]) + return session +} + export async function fetchHistory(payload: { sessionKey: string friendlyId: string diff --git a/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx b/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx index d2885b0..c17fd3a 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx @@ -11,10 +11,12 @@ import { Link, useNavigate } from '@tanstack/react-router' import { useChatSettings } from '../hooks/use-chat-settings' import { useDeleteSession } from '../hooks/use-delete-session' import { useRenameSession } from '../hooks/use-rename-session' +import { useSessionMetadata } from '../hooks/use-session-metadata' import { useSessionShortcuts } from '../hooks/use-session-shortcuts' import { SettingsDialog } from './settings-dialog' import { SessionRenameDialog } from './sidebar/session-rename-dialog' import { SessionDeleteDialog } from './sidebar/session-delete-dialog' +import { SessionTagsDialog } from './sidebar/session-tags-dialog' import { SidebarSessions } from './sidebar/sidebar-sessions' import { CommandSessionDialog } from './command-session' import type { SessionMeta } from '../types' @@ -72,6 +74,7 @@ function ChatSidebarComponent({ } = useChatSettings() const { deleteSession } = useDeleteSession() const { renameSession } = useRenameSession() + const { updateMetadata } = useSessionMetadata() const transition = { duration: 0.15, ease: isCollapsed ? 'easeIn' : 'easeOut', @@ -85,6 +88,10 @@ function ChatSidebarComponent({ const [deleteSessionKey, setDeleteSessionKey] = useState(null) const [deleteFriendlyId, setDeleteFriendlyId] = useState(null) const [deleteSessionTitle, setDeleteSessionTitle] = useState('') + const [tagsDialogOpen, setTagsDialogOpen] = useState(false) + const [tagsSessionKey, setTagsSessionKey] = useState(null) + const [tagsSessionTitle, setTagsSessionTitle] = useState('') + const [tagsSessionTags, setTagsSessionTags] = useState>([]) const [searchDialogOpen, setSearchDialogOpen] = useState(false) const navigate = useNavigate() @@ -122,6 +129,34 @@ function ChatSidebarComponent({ setRenameSessionKey(null) } + function handleOpenTags(session: SessionMeta) { + setTagsSessionKey(session.key) + setTagsSessionTitle( + session.label || + session.title || + session.derivedTitle || + session.friendlyId, + ) + setTagsSessionTags(session.tags) + setTagsDialogOpen(true) + } + + function handleSaveTags(tags: Array) { + if (tagsSessionKey) { + void updateMetadata({ sessionKey: tagsSessionKey, tags }) + } + setTagsDialogOpen(false) + setTagsSessionKey(null) + setTagsSessionTags([]) + } + + function handleToggleArchive(session: SessionMeta) { + void updateMetadata({ + sessionKey: session.key, + archived: !session.archived, + }) + } + function handleOpenDelete(session: SessionMeta) { setDeleteSessionKey(session.key) setDeleteFriendlyId(session.friendlyId) @@ -312,6 +347,8 @@ function ChatSidebarComponent({ activeFriendlyId={activeFriendlyId} onSelect={onSelectSession} onRename={handleOpenRename} + onEditTags={handleOpenTags} + onToggleArchive={handleToggleArchive} onDelete={handleOpenDelete} /> @@ -385,6 +422,15 @@ function ChatSidebarComponent({ onCancel={() => setRenameDialogOpen(false)} /> + setTagsDialogOpen(false)} + /> + + archived: boolean + hasFailedRun: boolean session: CommandSession } @@ -46,12 +47,34 @@ type CommandSessionProps = { onSelect: (session: CommandSession) => void } +type FilterOption = { + value: SessionSearchFilter + label: string +} + +const filterOptions: Array = [ + { value: 'workspace', label: 'Workspace' }, + { value: 'pinned', label: 'Pinned' }, + { value: 'recent', label: 'Recent' }, + { value: 'failed', label: 'Failed' }, + { value: 'tagged', label: 'Tagged' }, + { value: 'archived', label: 'Archived' }, +] + function getSessionLabel(session: CommandSession) { return ( session.label || session.title || session.derivedTitle || session.friendlyId ) } +function filterPinnedSessions( + sessions: Array, + pinnedSessionKeys: Array, +) { + const pinned = new Set(pinnedSessionKeys) + return sessions.filter((session) => pinned.has(session.key)) +} + function CommandSessionDialog({ sessions, open, @@ -59,36 +82,73 @@ function CommandSessionDialog({ onSelect, }: CommandSessionProps) { const [value, setValue] = useState('') - const filter = useAutocompleteFilter({ sensitivity: 'base' }) + const [filterMode, setFilterMode] = useState('workspace') + const [remoteSessions, setRemoteSessions] = useState>( + [], + ) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const { pinnedSessionKeys } = usePinnedSessions() + + useEffect(() => { + if (!open) return + + const controller = new AbortController() + const timeout = window.setTimeout(() => { + setLoading(true) + setError(null) + void fetchSessionSearch({ + query: value, + filter: filterMode, + signal: controller.signal, + }) + .then((items) => { + if (controller.signal.aborted) return + const nextSessions = + filterMode === 'pinned' + ? filterPinnedSessions(items, pinnedSessionKeys) + : items + setRemoteSessions(nextSessions) + }) + .catch((err) => { + if (controller.signal.aborted) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + }, 120) + + return () => { + window.clearTimeout(timeout) + controller.abort() + } + }, [filterMode, open, pinnedSessionKeys, value]) + + const visibleSessions = open + ? remoteSessions.length > 0 || value.trim() || filterMode !== 'workspace' + ? remoteSessions + : sessions + : sessions const groupedItems = useMemo>(() => { return [ { value: 'Sessions', - items: sessions.map((session) => ({ + items: visibleSessions.map((session) => ({ value: session.key, label: getSessionLabel(session), friendlyId: session.friendlyId, + tags: session.tags, + archived: session.archived, + hasFailedRun: session.hasFailedRun, session, })), }, ] - }, [sessions]) - - const filteredGroups = useMemo(() => { - const query = value.trim() - if (!query) return groupedItems - - return groupedItems - .map((group) => ({ - ...group, - items: group.items.filter((item) => - filter.contains(item, query, (target) => target.label), - ), - })) - .filter((group) => group.items.length > 0) - }, [filter, groupedItems, value]) + }, [visibleSessions]) + const filteredGroups = groupedItems.filter((group) => group.items.length > 0) const isEmpty = filteredGroups.length === 0 return ( @@ -102,14 +162,37 @@ function CommandSessionDialog({ > +
+ {filterOptions.map((option) => ( + + ))} +
+ {error ? ( +
+ {error} +
+ ) : null} {isEmpty ? (
- No sessions found. + {loading ? 'Searching sessions...' : 'No sessions found.'}
) : ( {filteredGroups.map((group, index) => ( - + {group.value} @@ -120,8 +203,34 @@ function CommandSessionDialog({ onClick={() => onSelect(item.session)} className="gap-2" > - - {item.label} + + + {item.label} + + {item.tags.length > 0 || + item.archived || + item.hasFailedRun ? ( + + {item.archived ? ( + + archived + + ) : null} + {item.hasFailedRun ? ( + + failed + + ) : null} + {item.tags.slice(0, 3).map((tag: string) => ( + + {tag} + + ))} + + ) : null} )} @@ -139,8 +248,16 @@ function CommandSessionDialog({
- - + + Navigate
diff --git a/apps/codex-claw/src/screens/chat/components/sidebar/session-item.tsx b/apps/codex-claw/src/screens/chat/components/sidebar/session-item.tsx index 3ec6e85..fc72f9b 100644 --- a/apps/codex-claw/src/screens/chat/components/sidebar/session-item.tsx +++ b/apps/codex-claw/src/screens/chat/components/sidebar/session-item.tsx @@ -3,10 +3,12 @@ import { Link } from '@tanstack/react-router' import { HugeiconsIcon } from '@hugeicons/react' import { + ArchiveIcon, Delete01Icon, MoreHorizontalIcon, Pen01Icon, PinIcon, + Tag01Icon, } from '@hugeicons/core-free-icons' import { memo } from 'react' import type { SessionMeta } from '../../types' @@ -25,6 +27,8 @@ type SessionItemProps = { onSelect?: () => void onTogglePin: (session: SessionMeta) => void onRename: (session: SessionMeta) => void + onEditTags: (session: SessionMeta) => void + onToggleArchive: (session: SessionMeta) => void onDelete: (session: SessionMeta) => void } @@ -35,10 +39,13 @@ function SessionItemComponent({ onSelect, onTogglePin, onRename, + onEditTags, + onToggleArchive, onDelete, }: SessionItemProps) { const label = session.label || session.title || session.derivedTitle || session.friendlyId + const tags = session.tags.slice(0, 2) return (
{label}
+ {tags.length > 0 || session.archived ? ( +
+ {session.archived ? ( + + archived + + ) : null} + {tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null}
@@ -86,9 +110,31 @@ function SessionItemComponent({ }} className="gap-2" > - {' '} + {' '} {isPinned ? 'Unpin session' : 'Pin session'} + { + event.preventDefault() + event.stopPropagation() + onEditTags(session) + }} + className="gap-2" + > + {' '} + Tags + + { + event.preventDefault() + event.stopPropagation() + onToggleArchive(session) + }} + className="gap-2" + > + {' '} + {session.archived ? 'Unarchive' : 'Archive'} + { event.preventDefault() @@ -124,6 +170,8 @@ function areSessionItemsEqual(prev: SessionItemProps, next: SessionItemProps) { if (prev.onSelect !== next.onSelect) return false if (prev.onTogglePin !== next.onTogglePin) return false if (prev.onRename !== next.onRename) return false + if (prev.onEditTags !== next.onEditTags) return false + if (prev.onToggleArchive !== next.onToggleArchive) return false if (prev.onDelete !== next.onDelete) return false if (prev.session === next.session) return true return ( @@ -132,6 +180,9 @@ function areSessionItemsEqual(prev: SessionItemProps, next: SessionItemProps) { prev.session.label === next.session.label && prev.session.title === next.session.title && prev.session.derivedTitle === next.session.derivedTitle && + prev.session.archived === next.session.archived && + prev.session.hasFailedRun === next.session.hasFailedRun && + prev.session.tags.join(',') === next.session.tags.join(',') && prev.session.updatedAt === next.session.updatedAt ) } diff --git a/apps/codex-claw/src/screens/chat/components/sidebar/session-tags-dialog.tsx b/apps/codex-claw/src/screens/chat/components/sidebar/session-tags-dialog.tsx new file mode 100644 index 0000000..fd95d81 --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/sidebar/session-tags-dialog.tsx @@ -0,0 +1,84 @@ +'use client' + +import { + DialogClose, + DialogContent, + DialogDescription, + DialogRoot, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +type SessionTagsDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + sessionTitle: string + tags: Array + onSave: (tags: Array) => void + onCancel: () => void +} + +function parseTags(value: string) { + const seen = new Set() + const tags: Array = [] + for (const rawTag of value.split(',')) { + const tag = rawTag.trim().toLowerCase().replace(/\s+/g, '-') + if (!tag || seen.has(tag)) continue + seen.add(tag) + tags.push(tag) + } + return tags +} + +export function SessionTagsDialog({ + open, + onOpenChange, + sessionTitle, + tags, + onSave, + onCancel, +}: SessionTagsDialogProps) { + const defaultValue = tags.join(', ') + + function saveInput(input: HTMLInputElement) { + onSave(parseTags(input.value)) + } + + return ( + + +
+ Session Tags + + Add comma-separated tags for {sessionTitle}. + + { + if (event.key === 'Enter') { + event.preventDefault() + saveInput(event.currentTarget) + } + }} + className="w-full rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none focus:border-primary-400" + placeholder="bugfix, release, docs" + autoFocus + /> +
+ Cancel + +
+
+
+
+ ) +} diff --git a/apps/codex-claw/src/screens/chat/components/sidebar/sidebar-sessions.tsx b/apps/codex-claw/src/screens/chat/components/sidebar/sidebar-sessions.tsx index fc544da..47a2a96 100644 --- a/apps/codex-claw/src/screens/chat/components/sidebar/sidebar-sessions.tsx +++ b/apps/codex-claw/src/screens/chat/components/sidebar/sidebar-sessions.tsx @@ -24,6 +24,8 @@ type SidebarSessionsProps = { defaultOpen?: boolean onSelect?: () => void onRename: (session: SessionMeta) => void + onEditTags: (session: SessionMeta) => void + onToggleArchive: (session: SessionMeta) => void onDelete: (session: SessionMeta) => void } @@ -33,6 +35,8 @@ export const SidebarSessions = memo(function SidebarSessions({ defaultOpen = true, onSelect, onRename, + onEditTags, + onToggleArchive, onDelete, }: SidebarSessionsProps) { const { pinnedSessionKeys, togglePinnedSession } = usePinnedSessions() @@ -43,15 +47,26 @@ export const SidebarSessions = memo(function SidebarSessions({ ) const pinnedSessions = useMemo( - () => sessions.filter((session) => pinnedSessionSet.has(session.key)), + () => + sessions.filter( + (session) => !session.archived && pinnedSessionSet.has(session.key), + ), [sessions, pinnedSessionSet], ) const unpinnedSessions = useMemo( - () => sessions.filter((session) => !pinnedSessionSet.has(session.key)), + () => + sessions.filter( + (session) => !session.archived && !pinnedSessionSet.has(session.key), + ), [sessions, pinnedSessionSet], ) + const archivedSessions = useMemo( + () => sessions.filter((session) => session.archived), + [sessions], + ) + const handleTogglePin = useCallback( (session: SessionMeta) => { togglePinnedSession(session.key) @@ -89,6 +104,8 @@ export const SidebarSessions = memo(function SidebarSessions({ onSelect={onSelect} onTogglePin={handleTogglePin} onRename={onRename} + onEditTags={onEditTags} + onToggleArchive={onToggleArchive} onDelete={onDelete} /> ))} @@ -106,6 +123,29 @@ export const SidebarSessions = memo(function SidebarSessions({ onSelect={onSelect} onTogglePin={handleTogglePin} onRename={onRename} + onEditTags={onEditTags} + onToggleArchive={onToggleArchive} + onDelete={onDelete} + /> + ))} + + {archivedSessions.length > 0 ? ( +
+ Archived +
+ ) : null} + + {archivedSessions.map((session) => ( + ))} @@ -128,6 +168,8 @@ function areSidebarSessionsEqual( if (prev.defaultOpen !== next.defaultOpen) return false if (prev.onSelect !== next.onSelect) return false if (prev.onRename !== next.onRename) return false + if (prev.onEditTags !== next.onEditTags) return false + if (prev.onToggleArchive !== next.onToggleArchive) return false if (prev.onDelete !== next.onDelete) return false if (prev.sessions === next.sessions) return true if (prev.sessions.length !== next.sessions.length) return false @@ -139,6 +181,9 @@ function areSidebarSessionsEqual( if (prevSession.label !== nextSession.label) return false if (prevSession.title !== nextSession.title) return false if (prevSession.derivedTitle !== nextSession.derivedTitle) return false + if (prevSession.archived !== nextSession.archived) return false + if (prevSession.hasFailedRun !== nextSession.hasFailedRun) return false + if (prevSession.tags.join(',') !== nextSession.tags.join(',')) return false if (prevSession.updatedAt !== nextSession.updatedAt) return false } return true diff --git a/apps/codex-claw/src/screens/chat/hooks/use-session-metadata.ts b/apps/codex-claw/src/screens/chat/hooks/use-session-metadata.ts new file mode 100644 index 0000000..748d854 --- /dev/null +++ b/apps/codex-claw/src/screens/chat/hooks/use-session-metadata.ts @@ -0,0 +1,81 @@ +import { useCallback, useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { chatQueryKeys, updateSessionMetadata } from '../chat-queries' +import type { SessionMeta } from '../types' + +type SessionMetadataInput = { + sessionKey: string + tags?: Array + archived?: boolean +} + +type SessionMetadataResult = { + updateMetadata: (input: SessionMetadataInput) => Promise + updating: boolean + error: string | null +} + +function applySessionMetadata( + session: SessionMeta, + payload: SessionMetadataInput, +) { + if (session.key !== payload.sessionKey) return session + return { + ...session, + tags: payload.tags ?? session.tags, + archived: payload.archived ?? session.archived, + } +} + +export function useSessionMetadata(): SessionMetadataResult { + const queryClient = useQueryClient() + const [updating, setUpdating] = useState(false) + const [error, setError] = useState(null) + + const mutation = useMutation({ + mutationFn: updateSessionMetadata, + onMutate: async function onMutate(payload) { + setError(null) + await queryClient.cancelQueries({ queryKey: chatQueryKeys.sessions }) + const previousSessions = queryClient.getQueryData(chatQueryKeys.sessions) + + queryClient.setQueryData( + chatQueryKeys.sessions, + function update(sessions: unknown) { + if (!Array.isArray(sessions)) return sessions + return (sessions as Array).map((session) => + applySessionMetadata(session, payload), + ) + }, + ) + + return { previousSessions } + }, + onError: function onError(err, _payload, context) { + if (context?.previousSessions) { + queryClient.setQueryData( + chatQueryKeys.sessions, + context.previousSessions, + ) + } + setError(err instanceof Error ? err.message : String(err)) + }, + onSuccess: function onSuccess() { + queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions }) + }, + onSettled: function onSettled() { + setUpdating(false) + }, + }) + + const updateMetadata = useCallback( + async (input: SessionMetadataInput) => { + if (!input.sessionKey) return + setUpdating(true) + await mutation.mutateAsync(input) + }, + [mutation], + ) + + return { updateMetadata, updating, error } +} diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 18af956..983f269 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -59,6 +59,9 @@ export type SessionSummary = { label?: string title?: string derivedTitle?: string + tags?: Array + archived?: boolean + hasFailedRun?: boolean updatedAt?: number lastMessage?: GatewayMessage | null friendlyId?: string @@ -82,6 +85,9 @@ export type SessionMeta = { title?: string derivedTitle?: string label?: string + tags: Array + archived: boolean + hasFailedRun: boolean updatedAt?: number lastMessage?: GatewayMessage | null totalTokens?: number diff --git a/apps/codex-claw/src/screens/chat/utils.ts b/apps/codex-claw/src/screens/chat/utils.ts index 8ae8924..fd2479f 100644 --- a/apps/codex-claw/src/screens/chat/utils.ts +++ b/apps/codex-claw/src/screens/chat/utils.ts @@ -94,6 +94,11 @@ export function normalizeSessions( ? session.derivedTitle : undefined, label: typeof session.label === 'string' ? session.label : undefined, + tags: Array.isArray(session.tags) + ? session.tags.filter((tag): tag is string => typeof tag === 'string') + : [], + archived: session.archived === true, + hasFailedRun: session.hasFailedRun === true, updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : undefined, lastMessage: session.lastMessage ?? null, diff --git a/apps/codex-claw/src/server/codex-cli.test.ts b/apps/codex-claw/src/server/codex-cli.test.ts index b988665..322e7a9 100644 --- a/apps/codex-claw/src/server/codex-cli.test.ts +++ b/apps/codex-claw/src/server/codex-cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -10,6 +10,7 @@ import { listCodexSessions, listCodexWorkspaces, mergeAssistantText, + patchCodexSession, patchCodexWorkspace, processCodexJsonLine, resetCodexServerStateForTests, @@ -190,4 +191,117 @@ describe('codex workspace registry', function () { workspaces: [expect.objectContaining({ id: 'default' })], }) }) + + it('searches session titles, message text, tool summaries, and tags', function () { + const storePath = getCodexPaths().storePath + mkdirSync(path.dirname(storePath), { recursive: true }) + writeFileSync( + storePath, + JSON.stringify({ + version: 1, + sessions: [ + { + key: 'release-session', + friendlyId: 'release-session', + title: 'Release checklist', + derivedTitle: 'Release checklist', + tags: ['alpha', 'publishing'], + updatedAt: 300, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'prepare npm alpha notes' }], + timestamp: 300, + }, + ], + }, + { + key: 'failure-session', + friendlyId: 'failure-session', + title: 'CI triage', + derivedTitle: 'CI triage', + updatedAt: 200, + messages: [ + { + role: 'toolResult', + toolName: 'command_execution', + isError: true, + details: { command: 'pnpm test', exitCode: 1 }, + content: [{ type: 'text', text: 'vitest failure output' }], + timestamp: 200, + }, + ], + }, + { + key: 'archived-session', + friendlyId: 'archived-session', + title: 'Old workspace notes', + derivedTitle: 'Old workspace notes', + archived: true, + updatedAt: 100, + messages: [ + { + role: 'assistant', + content: [{ type: 'text', text: 'legacy archive note' }], + timestamp: 100, + }, + ], + }, + ], + }), + ) + resetCodexServerStateForTests() + + expect(listCodexSessions({ query: 'npm alpha' }).sessions).toEqual([ + expect.objectContaining({ friendlyId: 'release-session' }), + ]) + expect( + listCodexSessions({ + query: 'vitest failure', + filter: 'failed', + includeArchived: true, + }).sessions, + ).toEqual([ + expect.objectContaining({ + friendlyId: 'failure-session', + hasFailedRun: true, + }), + ]) + expect(listCodexSessions({ filter: 'tagged' }).sessions).toEqual([ + expect.objectContaining({ + friendlyId: 'release-session', + tags: ['alpha', 'publishing'], + }), + ]) + expect(listCodexSessions({ filter: 'archived' }).sessions).toEqual([ + expect.objectContaining({ + friendlyId: 'archived-session', + archived: true, + }), + ]) + expect(listCodexSessions().sessions).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ friendlyId: 'archived-session' }), + ]), + ) + }) + + it('stores normalized session tags and archive state', function () { + patchCodexSession({ + key: 'taggable-session', + tags: [' Release Notes ', 'release-notes', 'QA'], + archived: true, + }) + + expect( + listCodexSessions({ includeArchived: true }).sessions, + ).toContainEqual( + expect.objectContaining({ + friendlyId: 'taggable-session', + tags: ['release-notes', 'qa'], + archived: true, + }), + ) + expect(listCodexSessions().sessions).toEqual([]) + }) }) diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index c40e64a..bbda4d1 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -56,6 +56,8 @@ type SessionRecord = { label?: string title?: string derivedTitle?: string + tags?: Array + archived?: boolean updatedAt: number messages: Array lastMessage?: CodexMessage | null @@ -66,6 +68,15 @@ type SessionStore = { sessions: Array } +type SessionFilter = 'all' | 'recent' | 'failed' | 'tagged' | 'archived' + +type ListCodexSessionsInput = { + query?: string + filter?: string + tag?: string + includeArchived?: boolean +} + type CodexTaskStatus = | 'queued' | 'running' @@ -810,7 +821,106 @@ function titleFromMessage(message: string) { return `${title.slice(0, 45)}...` } -function normalizeSession(session: SessionRecord) { +function normalizeTag(value: unknown) { + if (typeof value !== 'string') return '' + return value.trim().toLowerCase().replace(/\s+/g, '-') +} + +function normalizeTags(value: unknown): Array { + if (!Array.isArray(value)) return [] + const tags: Array = [] + const seen = new Set() + for (const item of value) { + const tag = normalizeTag(item) + if (!tag || seen.has(tag)) continue + seen.add(tag) + tags.push(tag) + } + return tags +} + +function normalizeSessionFilter(value: unknown): SessionFilter { + if ( + value === 'recent' || + value === 'failed' || + value === 'tagged' || + value === 'archived' + ) { + return value + } + return 'all' +} + +function searchValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (!value || typeof value !== 'object') return '' + try { + return JSON.stringify(value) + } catch { + return '' + } +} + +function searchTextFromMessage(message: CodexMessage) { + const parts: Array = [] + if (message.role) parts.push(message.role) + if (message.toolName) parts.push(message.toolName) + if (message.details) parts.push(searchValue(message.details)) + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'text') parts.push(String(part.text ?? '')) + if (part.type === 'thinking') parts.push(String(part.thinking ?? '')) + if (part.type === 'toolCall') { + parts.push(String(part.name ?? '')) + parts.push(searchValue(part.arguments)) + parts.push(String(part.partialJson ?? '')) + } + } + } + return parts.join(' ') +} + +function searchTextFromSession(session: SessionRecord) { + return [ + session.key, + session.friendlyId, + session.label, + session.title, + session.derivedTitle, + normalizeTags(session.tags).join(' '), + ...session.messages.map(searchTextFromMessage), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} + +function failedTaskSessionKeys() { + const failedKeys = new Set() + for (const task of readTaskStore().tasks) { + if (task.status !== 'failed') continue + failedKeys.add(task.sessionKey) + failedKeys.add(deriveFriendlyIdFromKey(task.sessionKey)) + } + return failedKeys +} + +function sessionHasFailedRun( + session: SessionRecord, + failedSessionKeys = new Set(), +) { + if (failedSessionKeys.has(session.key)) return true + if (failedSessionKeys.has(session.friendlyId)) return true + return session.messages.some((message) => message.isError === true) +} + +function normalizeSession( + session: SessionRecord, + failedSessionKeys = new Set(), +) { const lastMessage = session.lastMessage ?? (session.messages.length > 0 @@ -822,6 +932,9 @@ function normalizeSession(session: SessionRecord) { label: session.label, title: session.title, derivedTitle: session.derivedTitle, + tags: normalizeTags(session.tags), + archived: session.archived === true, + hasFailedRun: sessionHasFailedRun(session, failedSessionKeys), updatedAt: session.updatedAt, lastMessage, } @@ -1965,11 +2078,46 @@ export function getCodexHistory(input: { } } -export function listCodexSessions() { +export function listCodexSessions(input: ListCodexSessionsInput = {}) { const store = readStore() + const query = typeof input.query === 'string' ? input.query.trim() : '' + const normalizedQuery = query.toLowerCase() + const filter = normalizeSessionFilter(input.filter) + const tag = normalizeTag(input.tag) + const failedKeys = failedTaskSessionKeys() + let sessions = store.sessions.slice() + + if (filter === 'archived') { + sessions = sessions.filter((session) => session.archived === true) + } else if (!input.includeArchived) { + sessions = sessions.filter((session) => session.archived !== true) + } + + if (filter === 'failed') { + sessions = sessions.filter((session) => + sessionHasFailedRun(session, failedKeys), + ) + } + + if (filter === 'tagged') { + sessions = sessions.filter((session) => normalizeTags(session.tags).length) + } + + if (tag) { + sessions = sessions.filter((session) => + normalizeTags(session.tags).includes(tag), + ) + } + + if (normalizedQuery) { + sessions = sessions.filter((session) => + searchTextFromSession(session).includes(normalizedQuery), + ) + } + return { - sessions: store.sessions - .map(normalizeSession) + sessions: sessions + .map((session) => normalizeSession(session, failedKeys)) .sort((a, b) => b.updatedAt - a.updatedAt), } } @@ -2077,12 +2225,34 @@ export function retryCodexTask(taskId: string) { } } -export function patchCodexSession(input: { key?: string; label?: string }) { +export function patchCodexSession(input: { + key?: string + label?: string + tags?: Array + archived?: boolean +}) { const session = ensureSession(input.key || randomUUID(), input.label) + let changed = false + + if (Array.isArray(input.tags)) { + session.tags = normalizeTags(input.tags) + changed = true + } + + if (typeof input.archived === 'boolean') { + session.archived = input.archived + changed = true + } + + if (changed) { + session.updatedAt = Date.now() + writeStore(readStore()) + } + return { ok: true, key: session.key, - entry: normalizeSession(session), + entry: normalizeSession(session, failedTaskSessionKeys()), } }