Files
FabledScribe/frontend/src/components/ToolCallCard.vue
T
bvandeusen e0c3836fab feat: event cards in chat/briefing, upcoming events on dashboard, briefing uses internal store
- ToolCallCard: event list items replaced with rich clickable cards (color dot,
  title, time, location); clicking opens EventSlideOver for edit/delete; single
  create/update events in header are also clickable; updated all event types to
  use start_dt/end_dt fields from internal store
- HomeView: new upcoming events widget shows today + next 7 days as a card grid
  above the hero project; clicking any card opens EventSlideOver inline
- briefing_pipeline: _gather_internal now queries the internal events store for
  today's events; CalDAV events are still appended (deduped) if configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 15:39:13 -04:00

810 lines
28 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { apiPost, getEvent } from "@/api/client";
import type { EventEntry } from "@/api/client";
import type { ToolCallRecord } from "@/types/chat";
import EventSlideOver from "@/components/EventSlideOver.vue";
const props = defineProps<{
toolCall: ToolCallRecord;
}>();
const appliedTags = ref<Set<string>>(new Set());
const applyingTag = ref<string | null>(null);
const confirmState = ref<"idle" | "creating" | "created" | "denied">("idle");
const label = computed(() => {
if (props.toolCall.status === "declined") return "Declined";
if (props.toolCall.result.requires_confirmation) return "Similar content found";
if (!props.toolCall.result.success) return "Error";
switch (props.toolCall.result.type) {
case "task": return "Created task";
case "note": return "Created note";
case "note_updated": return "Updated note";
case "note_deleted": return "Deleted note";
case "task_deleted": return "Deleted task";
case "note_content": return "Note";
case "notes_list": return "Notes";
case "tasks": return "Tasks";
case "todo_updated": return "Updated todo";
case "search": return "Searched notes";
case "event": return "Created event";
case "events": return "Found events";
case "event_updated": return "Updated event";
case "event_deleted": return "Deleted event";
case "calendars": return "Calendars";
case "todo": return "Created todo";
case "todos": return "Calendar todos";
case "todo_completed": return "Completed todo";
case "todo_deleted": return "Deleted todo";
case "web_search": return "Web search";
case "research_pending": return "Research started";
case "research_note": return "Research note";
case "image_search": return "Image search";
default: return "Tool call";
}
});
const title = computed(() => {
const data = props.toolCall.result.data;
if (!data) return null;
if (typeof data.title === "string") return data.title;
return null;
});
const linkTo = computed(() => {
const data = props.toolCall.result.data;
if (!data || typeof data.id !== "number") return null;
return `/notes/${data.id}`;
});
const noteId = computed(() => {
const data = props.toolCall.result.data;
if (!data || typeof data.id !== "number") return null;
return data.id;
});
const suggestedTags = computed(() => props.toolCall.result.suggested_tags ?? []);
const eventData = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event") return null;
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
});
const updatedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_updated") return null;
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
});
const deletedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_deleted") return null;
return data as { id?: number; title: string };
});
const calendarList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "calendars") return null;
const calendars = data.calendars as Array<{ name: string }> | undefined;
return calendars?.map((c) => c.name) ?? [];
});
const todoData = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted" && type !== "todo_updated")) return null;
return data as { summary: string; due?: string; status?: string };
});
const todoList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return null;
return (data.todos as Array<{ summary: string; due?: string; status?: string }> | undefined) ?? [];
});
const todoCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return 0;
return (data.count as number) ?? 0;
});
const eventList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "events") return null;
return (data.events as Array<{ id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string }> | undefined) ?? [];
});
const eventCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "events") return 0;
return (data.count as number) ?? 0;
});
function formatEventTime(iso: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleString(undefined, {
weekday: "short", month: "short", day: "numeric",
hour: "numeric", minute: "2-digit",
});
} catch { return iso; }
}
const taskList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "tasks") return null;
return (data.results as Array<{ id: number; title: string; status: string; priority: string; due_date?: string }> | undefined) ?? [];
});
const taskCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "tasks") return 0;
return (data.total as number) ?? 0;
});
const webSearch = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "web_search") return null;
return data as { query: string; results: { url: string; title: string; snippet: string }[]; count: number };
});
const searchResults = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "search") return null;
const results = data.results as Array<{ id: number; title: string; type: string }> | undefined;
return results && results.length > 0 ? results : null;
});
const deletedNote = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "note_deleted" && type !== "task_deleted")) return null;
return data as { id: number; title: string };
});
const noteContent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "note_content") return null;
return data as { id: number; title: string; body: string; tags: string[] };
});
const noteList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return null;
return (data.results as Array<{ id: number; title: string; tags: string[]; preview: string }> | undefined) ?? [];
});
const noteListCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return 0;
return (data.total as number) ?? 0;
});
// ── Collapse logic ───────────────────────────────────────────────────────────
// Cards with rich expandable content
const hasDetail = computed(() => {
if (props.toolCall.status === "running") return false;
return (
(noteContent.value !== null && noteContent.value.tags.length > 0) ||
(noteList.value !== null && noteList.value.length > 0) ||
searchResults.value !== null ||
(taskList.value !== null && taskList.value.length > 0) ||
(eventList.value !== null && eventList.value.length > 0) ||
(todoList.value !== null && todoList.value.length > 0) ||
webSearch.value !== null ||
(calendarList.value !== null && calendarList.value.length > 0) ||
suggestedTags.value.length > 0
);
});
// Collapsed by default for completed calls; errors and running stay open
const collapsed = ref(
props.toolCall.status === "success" || props.toolCall.status === "declined"
);
// Auto-collapse when a running tool call completes
watch(() => props.toolCall.status, (s) => {
if (s === "success") collapsed.value = true;
});
function toggle() {
if (hasDetail.value) collapsed.value = !collapsed.value;
}
async function confirmDuplicate() {
if (confirmState.value !== "idle") return;
confirmState.value = "creating";
const args = props.toolCall.arguments as Record<string, unknown>;
const isTask = props.toolCall.function === "create_task";
const endpoint = isTask ? "/api/tasks" : "/api/notes";
const payload: Record<string, unknown> = {
title: args.title,
body: args.body ?? "",
tags: args.tags ?? [],
...(args.due_date ? { due_date: args.due_date } : {}),
...(args.priority ? { priority: args.priority } : {}),
...(args.project ? { project: args.project } : {}),
...(isTask ? { status: args.status ?? "todo" } : {}),
};
try {
await apiPost(endpoint, payload);
confirmState.value = "created";
} catch {
confirmState.value = "idle";
}
}
function denyDuplicate() {
confirmState.value = "denied";
}
async function applyTag(tag: string) {
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
applyingTag.value = tag;
try {
await apiPost(`/api/notes/${noteId.value}/append-tag`, { tag });
appliedTags.value.add(tag);
} catch {
// silently fail
} finally {
applyingTag.value = null;
}
}
// ── Event slide-over ─────────────────────────────────────────────────────────
const eventSlideOverOpen = ref(false);
const eventSlideOverEntry = ref<EventEntry | null>(null);
async function openEventSlideOver(id: number | undefined) {
if (!id) return;
try {
const entry = await getEvent(id);
eventSlideOverEntry.value = entry;
eventSlideOverOpen.value = true;
} catch {
// silently fail — event may have been deleted
}
}
function closeEventSlideOver() {
eventSlideOverOpen.value = false;
}
</script>
<template>
<div
class="tool-call-card"
:class="{
error: toolCall.status === 'error' && !toolCall.result.requires_confirmation,
'requires-confirm': toolCall.result.requires_confirmation,
declined: toolCall.status === 'declined',
running: toolCall.status === 'running',
collapsible: hasDetail,
}"
>
<!-- ── Header row (always visible) ─────────────────────────── -->
<div class="tool-card-header" :class="{ clickable: hasDetail }" @click="toggle">
<span class="tool-label">{{ label }}</span>
<!-- Inline summary content -->
<template v-if="toolCall.status === 'declined'">
<span class="tool-declined-name">
{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}
</span>
</template>
<template v-else-if="toolCall.result.requires_confirmation">
<router-link
v-if="toolCall.result.similar_note"
:to="`/notes/${toolCall.result.similar_note.id}`"
class="tool-link"
@click.stop
>{{ toolCall.result.similar_note.title }}</router-link>
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
<template v-if="confirmState === 'created'">
<span class="confirm-created"> Created</span>
</template>
<template v-else-if="confirmState === 'denied'">
<span class="confirm-denied">Skipped</span>
</template>
<template v-else-if="confirmState !== 'creating'">
<button class="btn-confirm-duplicate" @click.stop="confirmDuplicate">Create anyway</button>
<button class="btn-deny-duplicate" @click.stop="denyDuplicate">Skip</button>
</template>
<template v-else>
<span class="tool-summary-count">Creating</span>
</template>
</template>
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
<template v-else-if="deletedNote">
<span class="tool-deleted">{{ deletedNote.title || "Untitled" }}</span>
</template>
<template v-else-if="noteContent">
<router-link :to="`/notes/${noteContent.id}`" class="tool-link" @click.stop>
{{ noteContent.title || "Untitled" }}
</router-link>
</template>
<template v-else-if="noteList !== null">
<span class="tool-summary-count">{{ noteListCount }} found</span>
</template>
<template v-else-if="searchResults">
<span class="tool-summary-count">
{{ (toolCall.result.data?.count as number) ?? (toolCall.result.data?.total as number) ?? 0 }} found
</span>
</template>
<template v-else-if="eventData">
<button v-if="eventData.id" class="tool-event-btn" @click.stop="openEventSlideOver(eventData.id)">
<span class="tool-event-dot" v-if="eventData.color" :style="{ background: eventData.color }"></span>
<span class="tool-event-title">{{ eventData.title }}</span>
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
</button>
<template v-else>
<span class="tool-event-title">{{ eventData.title }}</span>
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
</template>
</template>
<template v-else-if="updatedEvent">
<button v-if="updatedEvent.id" class="tool-event-btn" @click.stop="openEventSlideOver(updatedEvent.id)">
<span class="tool-event-dot" v-if="updatedEvent.color" :style="{ background: updatedEvent.color }"></span>
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
</button>
<template v-else>
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
</template>
</template>
<template v-else-if="deletedEvent">
<span class="tool-deleted">{{ deletedEvent.title }}</span>
</template>
<template v-else-if="calendarList !== null">
<span class="tool-summary-count">{{ calendarList.length }} calendar{{ calendarList.length !== 1 ? "s" : "" }}</span>
</template>
<template v-else-if="todoData">
<span :class="{ 'tool-deleted': toolCall.result.type === 'todo_deleted', 'tool-completed': toolCall.result.type === 'todo_completed' }">
{{ todoData.summary }}
</span>
<span v-if="todoData.due" class="tool-event-time">{{ formatEventTime(todoData.due) }}</span>
</template>
<template v-else-if="todoList !== null">
<span class="tool-summary-count">{{ todoCount }} found</span>
</template>
<template v-else-if="taskList !== null">
<span class="tool-summary-count">{{ taskCount }} found</span>
</template>
<template v-else-if="eventList !== null">
<span class="tool-summary-count">{{ eventCount }} found</span>
</template>
<template v-else-if="webSearch">
<span class="tool-summary-count">
{{ webSearch.count }} result{{ webSearch.count !== 1 ? "s" : "" }}
</span>
</template>
<template v-else-if="linkTo">
<router-link :to="linkTo" class="tool-link" @click.stop>{{ title }}</router-link>
</template>
<!-- Chevron toggle -->
<span v-if="hasDetail" class="tool-chevron" :class="{ open: !collapsed }"></span>
</div>
<!-- ── Detail section (expandable) ─────────────────────────── -->
<div v-if="hasDetail" v-show="!collapsed" class="tool-card-detail">
<template v-if="noteContent && noteContent.tags.length">
<span class="tool-note-tags">
<span v-for="tag in noteContent.tags" :key="tag" class="tool-note-tag">#{{ tag }}</span>
</span>
</template>
<template v-else-if="noteList !== null && noteList.length > 0">
<div class="tool-search-results">
<router-link
v-for="n in noteList.slice(0, 8)"
:key="n.id"
:to="`/notes/${n.id}`"
class="tool-search-item"
>{{ n.title || "Untitled" }}</router-link>
<span v-if="noteList.length > 8" class="tool-event-more">+{{ noteList.length - 8 }} more</span>
</div>
</template>
<template v-else-if="searchResults">
<div class="tool-search-results">
<router-link
v-for="r in searchResults"
:key="r.id"
:to="`/notes/${r.id}`"
class="tool-search-item"
>{{ r.title || "Untitled" }}</router-link>
</div>
</template>
<template v-else-if="todoList !== null && todoList.length > 0">
<div class="tool-event-list">
<div v-for="(t, i) in todoList.slice(0, 5)" :key="i" class="tool-event-item">
<span class="tool-event-item-title">{{ t.summary }}</span>
<span v-if="t.due" class="tool-event-item-time">{{ formatEventTime(t.due) }}</span>
</div>
<div v-if="todoList.length > 5" class="tool-event-more">+{{ todoList.length - 5 }} more</div>
</div>
</template>
<template v-else-if="taskList !== null && taskList.length > 0">
<div class="tool-event-list">
<div v-for="(t, i) in taskList.slice(0, 5)" :key="i" class="tool-event-item">
<router-link :to="`/tasks/${t.id}`" class="tool-event-item-title">{{ t.title }}</router-link>
<span v-if="t.due_date" class="tool-event-item-time">{{ t.due_date }}</span>
<span v-if="t.priority && t.priority !== 'none'" class="tool-task-priority" :class="`priority-${t.priority}`">{{ t.priority }}</span>
</div>
<div v-if="taskList.length > 5" class="tool-event-more">+{{ taskList.length - 5 }} more</div>
</div>
</template>
<template v-else-if="eventList !== null && eventList.length > 0">
<div class="tool-event-cards">
<button
v-for="(ev, i) in eventList.slice(0, 5)"
:key="i"
class="tool-event-card"
:class="{ clickable: !!ev.id }"
@click="openEventSlideOver(ev.id)"
>
<span class="tool-event-card-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="tool-event-card-body">
<span class="tool-event-card-title">{{ ev.title }}</span>
<span v-if="ev.start_dt" class="tool-event-card-time">{{ formatEventTime(ev.start_dt) }}</span>
<span v-if="ev.location" class="tool-event-card-loc">{{ ev.location }}</span>
</span>
</button>
<div v-if="eventList.length > 5" class="tool-event-more">+{{ eventList.length - 5 }} more</div>
</div>
</template>
<template v-else-if="calendarList !== null">
<div class="tool-calendar-list">
<span v-for="name in calendarList" :key="name" class="tool-calendar-name">{{ name }}</span>
</div>
</template>
<template v-else-if="webSearch">
<div class="tool-search-results web-search-results">
<a
v-for="(r, i) in webSearch.results"
:key="i"
:href="r.url"
target="_blank"
rel="noopener noreferrer"
class="tool-search-item"
>{{ r.title || r.url }}</a>
</div>
</template>
<!-- Suggested tags always at the bottom of detail -->
<div v-if="suggestedTags.length > 0" class="tag-suggestions">
<span class="tag-suggestions-label">Suggested tags:</span>
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag) || applyingTag === tag"
@click="applyTag(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
</div>
</div>
<!-- Suggested tags for non-detail cards (note/task created etc.) -->
<div v-if="!hasDetail && suggestedTags.length > 0" class="tag-suggestions">
<span class="tag-suggestions-label">Suggested tags:</span>
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag) || applyingTag === tag"
@click="applyTag(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
</div>
</div>
<!-- Event slide-over (portal-like, fixed positioned) -->
<EventSlideOver
v-if="eventSlideOverOpen"
:event="eventSlideOverEntry"
initial-date=""
@close="closeEventSlideOver"
@created="closeEventSlideOver"
@updated="closeEventSlideOver"
@deleted="closeEventSlideOver"
/>
</template>
<style scoped>
.tool-call-card {
display: inline-block;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
font-size: 0.8rem;
margin-top: 0.4rem;
overflow: hidden;
vertical-align: top;
}
.tool-call-card.error {
border-color: var(--color-danger, #e74c3c);
}
.tool-call-card.declined {
opacity: 0.55;
}
/* ── Header ── */
.tool-card-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
}
.tool-card-header.clickable {
cursor: pointer;
user-select: none;
}
.tool-card-header.clickable:hover {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
}
.tool-label {
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 0.7rem;
flex-shrink: 0;
}
.tool-chevron {
font-size: 0.55rem;
color: var(--color-text-muted);
margin-left: auto;
padding-left: 0.4rem;
transition: transform 0.15s ease;
flex-shrink: 0;
}
.tool-chevron.open {
transform: rotate(90deg);
}
.tool-summary-count {
color: var(--color-text-muted);
font-size: 0.8rem;
}
/* ── Detail ── */
.tool-card-detail {
padding: 0.3rem 0.6rem 0.45rem;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Shared content styles ── */
.tool-link {
color: var(--color-primary);
text-decoration: none;
}
.tool-link:hover { text-decoration: underline; }
.tool-error { color: var(--color-danger, #e74c3c); }
.tool-declined-name {
text-decoration: line-through;
color: var(--color-text-muted);
}
.tool-search-results {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tool-search-item {
color: var(--color-primary);
text-decoration: none;
font-size: 0.8rem;
}
.tool-search-item:hover { text-decoration: underline; }
.tool-search-item:not(:last-child)::after {
content: ",";
color: var(--color-text-muted);
margin-right: 0.1rem;
}
.web-search-results {
flex-direction: column;
flex-wrap: nowrap;
}
.web-search-results .tool-search-item::after { content: none; }
.tool-event-title { font-weight: 600; color: var(--color-text); }
.tool-event-time { color: var(--color-text-muted); font-size: 0.75rem; }
.tool-event-list { display: flex; flex-direction: column; gap: 0.2rem; }
.tool-event-item { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
.tool-event-item-title { color: var(--color-text); font-size: 0.8rem; }
.tool-event-item-time { color: var(--color-text-muted); font-size: 0.75rem; white-space: nowrap; }
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; font-style: italic; }
.tool-deleted { text-decoration: line-through; opacity: 0.7; }
.tool-completed { text-decoration: line-through; color: var(--color-success, #2ecc71); }
.tool-note-tags { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.tool-note-tag { font-size: 0.72rem; color: var(--color-text-muted); }
.tool-task-priority {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; padding: 0.1rem 0.3rem; border-radius: 3px;
}
.priority-high { color: var(--color-danger, #e74c3c); }
.priority-medium { color: var(--color-warning, #f59e0b); }
.priority-low { color: var(--color-text-muted); }
.tool-calendar-list { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tool-calendar-name {
display: inline-block; padding: 0.1rem 0.5rem;
border-radius: 999px; background: var(--color-bg-secondary);
border: 1px solid var(--color-border); font-size: 0.75rem; color: var(--color-text);
}
/* ── Tag suggestions ── */
.tag-suggestions {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem;
padding: 0.3rem 0.6rem; border-top: 1px solid var(--color-border);
}
.tool-call-card:not(:has(.tool-card-detail)) .tag-suggestions {
/* When outside detail, no top border needed if it's the only extra content */
}
.tag-suggestions-label { font-size: 0.7rem; color: var(--color-text-muted); font-weight: 500; }
.tag-pill {
display: inline-flex; align-items: center; gap: 0.2rem;
padding: 0.15rem 0.5rem; border: 1px solid var(--color-primary);
border-radius: 999px; background: transparent; color: var(--color-primary);
font-size: 0.75rem; cursor: pointer; transition: background 0.15s, color 0.15s;
}
.tag-pill:hover:not(:disabled) { background: var(--color-primary); color: #fff; }
.tag-pill.applied {
background: var(--color-success, #2ecc71); border-color: var(--color-success, #2ecc71);
color: #fff; cursor: default;
}
.tag-pill:disabled:not(.applied) { opacity: 0.6; cursor: wait; }
.tag-check { font-size: 0.65rem; }
/* Duplicate confirmation */
.tool-call-card.requires-confirm {
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
}
.confirm-created {
color: var(--color-success, #2ecc71);
font-size: 0.78rem;
font-weight: 600;
}
.confirm-denied {
color: var(--color-text-muted);
font-size: 0.78rem;
font-style: italic;
}
.btn-confirm-duplicate {
padding: 0.15rem 0.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm, 6px);
cursor: pointer;
font-size: 0.72rem;
font-family: inherit;
white-space: nowrap;
}
.btn-confirm-duplicate:hover { opacity: 0.9; }
.btn-deny-duplicate {
padding: 0.15rem 0.5rem;
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
font-size: 0.72rem;
font-family: inherit;
white-space: nowrap;
}
.btn-deny-duplicate:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
/* ── Event header click button ── */
.tool-event-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
}
.tool-event-btn:hover .tool-event-title {
text-decoration: underline;
color: var(--color-primary);
}
.tool-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
flex-shrink: 0;
}
/* ── Event cards (list) ── */
.tool-event-cards { display: flex; flex-direction: column; gap: 0.25rem; }
.tool-event-card {
display: flex;
align-items: flex-start;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-bg-card, #16161a);
text-align: left;
font-family: inherit;
cursor: default;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.tool-event-card.clickable { cursor: pointer; }
.tool-event-card.clickable:hover {
border-color: var(--color-primary, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
}
.tool-event-card-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
flex-shrink: 0;
margin-top: 3px;
}
.tool-event-card-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tool-event-card-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-event-card-time {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.tool-event-card-loc {
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>