Skip to main content
Glama
MessageItem.svelte11.1 kB
<script lang="ts"> import type { Message } from '$lib/api' import * as api from '$lib/api' import { formatDate, getMessageContent, parseIdeTags } from '$lib/utils' import ExpandableContent from './ExpandableContent.svelte' import IdeTag from './IdeTag.svelte' import TodoItem from './TodoItem.svelte' interface Props { msg: Message sessionId: string isFirst?: boolean onDelete: (msg: Message) => void onEditTitle?: (msg: Message) => void onSplit?: (msg: Message) => void } let { msg, sessionId, isFirst = false, onDelete, onEditTitle, onSplit }: Props = $props() // Type guards for different message types const isAssistant = $derived(msg.type === 'assistant') const isCustomTitle = $derived(msg.type === 'custom-title') const isFileSnapshot = $derived(msg.type === 'file-history-snapshot') const isHuman = $derived(msg.type === 'human' || msg.type === 'user') const isLocalCommand = $derived(msg.type === 'system' && msg.subtype === 'local_command') const isQueueOperation = $derived(msg.type === 'queue-operation') const isToolResult = $derived( msg.type === 'user' && msg.message?.content?.[0]?.type === 'tool_result' ) // Parse file snapshot data const snapshotData = $derived.by(() => { if (!isFileSnapshot) return null const snapshot = ( msg as unknown as { snapshot?: { messageId?: string trackedFileBackups?: Record<string, { backupFileName?: string }> timestamp?: string } } ).snapshot const backups = snapshot?.trackedFileBackups ?? {} return { files: Object.entries(backups), timestamp: snapshot?.timestamp, } }) // Parse command data const commandData = $derived.by(() => { if (!isLocalCommand) return null const name = msg.content?.match(/<command-name>([^<]+)<\/command-name>/)?.[1] ?? '' const message = msg.content?.match(/<command-message>([^<]+)<\/command-message>/)?.[1] ?? '' return { name, message } }) // Parse tool_use data from assistant messages interface ToolUse { type: 'tool_use' name: string input: Record<string, unknown> } const FILE_TOOLS = ['Read', 'Write', 'Edit'] as const const toolUseData = $derived.by(() => { if (!isAssistant) return null const m = msg.message as { content?: unknown[] } | undefined if (!Array.isArray(m?.content)) return null const toolUse = m.content.find( (item): item is ToolUse => typeof item === 'object' && item !== null && (item as ToolUse).type === 'tool_use' ) if (!toolUse) return null return { name: toolUse.name, input: toolUse.input, // Extract file path for file tools (Read, Write, Edit) filePath: FILE_TOOLS.includes(toolUse.name as (typeof FILE_TOOLS)[number]) ? (toolUse.input.file_path as string) : null, } }) // Get custom title const customTitle = $derived((msg as Message & { customTitle?: string }).customTitle ?? '') // Get message ID (uuid or messageId for file-history-snapshot) const messageId = $derived(msg.uuid || (msg as unknown as { messageId?: string }).messageId || '') // Check if message has displayable content const hasContent = $derived.by(() => { // Queue operations have no displayable content but are valid if (isQueueOperation) return false if (isFileSnapshot || isLocalCommand || isCustomTitle || toolUseData) return true // Check message.content first (primary content source) const content = getMessageContent(msg) if (content.trim().length > 0) return true // Warn for messages without content if (msg.type === 'user' || msg.type === 'human') { const label = isToolResult ? 'Tool result' : 'User message' console.warn(`${label} without content:`, $state.snapshot(msg)) } return false }) // CSS classes for message type const messageClass = $derived.by(() => { if (isHuman) return 'bg-gh-accent/15 border-l-3 border-l-gh-accent' if (isAssistant) return 'bg-gh-green/15 border-l-3 border-l-gh-green' if (isCustomTitle) return 'bg-purple-500/15 border-l-3 border-l-purple-500' return 'bg-gh-border-subtle' }) </script> {#snippet splitButton()} {#if onSplit && !isFirst} <button class="opacity-0 group-hover:opacity-100 transition-opacity bg-transparent border-none cursor-pointer p-1 rounded hover:bg-gh-accent/20 text-xs" onclick={() => onSplit(msg)} title="Split session from this message" > ✂️ </button> {/if} {/snippet} {#snippet deleteButton()} <button class="opacity-0 group-hover:opacity-100 transition-opacity bg-transparent border-none cursor-pointer p-1 rounded hover:bg-gh-red/20 text-xs" onclick={() => onDelete(msg)} title="Delete message" > 🗑️ </button> {/snippet} {#if isQueueOperation} <!-- Queue operations are internal system messages, don't render --> {:else if isFileSnapshot && snapshotData} <!-- File history snapshot --> <div class="p-4 rounded-lg bg-amber-500/10 border-l-3 border-l-amber-500 group relative"> <div class="flex justify-between mb-2 text-xs text-gh-text-secondary"> <span class="uppercase font-semibold text-amber-400"> 📁 File Backups ({snapshotData.files.length}) </span> <div class="flex items-center gap-2"> <span>{formatDate(snapshotData.timestamp)}</span> {@render splitButton()} {@render deleteButton()} </div> </div> <ul class="space-y-1"> {#each snapshotData.files as [filePath, info]} {@const hasBackup = !!(info.backupFileName && sessionId)} <li class="font-mono text-xs truncate" title={filePath}> {#if hasBackup} <button class="text-gh-accent hover:underline cursor-pointer bg-transparent border-none p-0" onclick={() => api.openFileInVscode(sessionId, info.backupFileName!)} title="Open backup in VS Code" > {filePath} </button> {:else} <span class="text-gh-text-secondary">{filePath}</span> {/if} </li> {/each} </ul> </div> {:else if isLocalCommand && commandData} <!-- Local command message --> <div class="p-3 rounded-lg bg-cyan-500/10 border-l-3 border-l-cyan-500 group relative"> <div class="flex justify-between items-center text-xs text-gh-text-secondary"> <span class="font-semibold text-cyan-400">⚡ {commandData.name || 'Command'}</span> <div class="flex items-center gap-2"> <span>{formatDate(msg.timestamp)}</span> {@render splitButton()} {@render deleteButton()} </div> </div> {#if commandData.message && commandData.message !== commandData.name?.slice(1)} <p class="mt-1 text-sm text-gh-text-secondary">{commandData.message}</p> {/if} </div> {:else if toolUseData} <!-- Tool use message --> <div class="p-3 rounded-lg bg-violet-500/10 border-l-3 border-l-violet-500 group relative"> <div class="flex justify-between items-center text-xs text-gh-text-secondary"> <span class="font-semibold text-violet-400">🔧 {toolUseData.name}</span> <div class="flex items-center gap-2"> <span>{formatDate(msg.timestamp)}</span> {@render splitButton()} {@render deleteButton()} </div> </div> {#if toolUseData.filePath} {#await api.checkFileExists(toolUseData.filePath)} <span class="mt-1 text-sm text-gh-text-secondary font-mono"> {toolUseData.filePath.split('/').pop()} </span> {:then exists} {#if exists} <button class="mt-1 text-sm text-gh-accent hover:underline cursor-pointer bg-transparent border-none p-0 font-mono truncate block max-w-full text-left" onclick={() => api.openFile(toolUseData.filePath!)} title={toolUseData.filePath} > {toolUseData.filePath.split('/').pop()} </button> {:else} <span class="mt-1 text-sm text-gh-text-secondary font-mono" title={toolUseData.filePath}> {toolUseData.filePath.split('/').pop()} </span> {/if} {/await} {:else if toolUseData.input.command} {#if toolUseData.input.description} <p class="mt-1 text-sm text-gh-text-secondary">{toolUseData.input.description}</p> {/if} <ExpandableContent content={String(toolUseData.input.command)} lang="sh" maxLines={3} /> {:else} {@const { path: _path, ...input } = toolUseData.input} {@const keys = Object.keys(input)} {#if keys.length === 1} {@const key = keys[0]} {@const value = input[key]} {(() => { console.info(`${key} =`, value) return '' })()} {#if key === 'todos' && Array.isArray(value)} <TodoItem todos={value} /> {:else} <ExpandableContent content={`${key} = ${JSON.stringify(value, null, 2)}`} lang="js" maxLines={1} /> {/if} {:else} <ExpandableContent content={JSON.stringify(input, null, 2)} lang="json" maxLines={6} /> {/if} {/if} </div> {:else} <!-- Standard message (human, assistant, custom-title, etc.) --> <div class="p-4 rounded-lg group relative {messageClass} flex flex-col {hasContent ? 'gap-2' : ''}" > <div class="flex justify-between text-xs text-gh-text-secondary"> <span class="uppercase font-semibold">{isToolResult ? 'OUT' : msg.type}</span> <div class="flex items-center gap-2"> <span class="group-hover:hidden">{formatDate(msg.timestamp)}</span> <span class="hidden group-hover:inline font-mono text-gh-text-secondary/70"> {messageId} </span> {#if isCustomTitle && onEditTitle} <button class="opacity-0 group-hover:opacity-100 transition-opacity bg-transparent border-none cursor-pointer p-1 rounded hover:bg-gh-border text-xs" onclick={() => onEditTitle(msg)} title="Edit title" > ✏️ </button> {/if} {@render splitButton()} {@render deleteButton()} </div> </div> {#if hasContent} <div class="message-content text-sm"> {#if isCustomTitle} <span class="font-semibold text-purple-400">{customTitle}</span> {:else} {@const msgContent = getMessageContent(msg)} {@const segments = parseIdeTags(msgContent)} {#each segments as segment} {#if segment.type === 'ide_tag' && segment.tag} <IdeTag tag={segment.tag} content={segment.content} /> {:else} {@const textLines = segment.content.split('\n')} {#if textLines.length > 10} <ExpandableContent content={segment.content} maxLines={10} /> {:else} <p class="whitespace-pre-wrap">{segment.content}</p> {/if} {/if} {/each} {/if} </div> {/if} </div> {/if}

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DrumRobot/claude-sessions-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server