Files
clientsflow/frontend/app/components/workspace/pilot/CrmPilotSidebar.vue

626 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
type ChatConversation = {
id: string;
title: string;
lastMessageText?: string | null;
};
type PilotMessage = {
id: string;
role: "user" | "assistant" | "system";
text: string;
messageKind?: string | null;
changeSummary?: string | null;
changeItems?: Array<{ id: string; entity: string; action: string; title: string }> | null;
changeSetId?: string | null;
changeStatus?: string | null;
createdAt?: string;
};
type ContextScopeChip = {
scope: string;
label: string;
};
defineProps<{
pilotHeaderText: string;
chatSwitching: boolean;
chatThreadsLoading: boolean;
chatConversations: ChatConversation[];
authMe: { conversation?: { title?: string | null } | null } | null;
chatCreating: boolean;
renderedPilotMessages: PilotMessage[];
pilotLiveLogs: Array<{ id: string; text: string }>;
pilotLiveLogsExpanded: boolean;
pilotLiveLogHiddenCount: number;
pilotVisibleLogCount: number;
pilotVisibleLiveLogs: Array<{ id: string; text: string }>;
chatThreadPickerOpen: boolean;
selectedChatId: string;
chatArchivingId: string;
pilotInput: string;
pilotRecording: boolean;
contextScopeChips: ContextScopeChip[];
contextPickerEnabled: boolean;
pilotTranscribing: boolean;
pilotSending: boolean;
pilotMicSupported: boolean;
pilotMicError: string | null;
toggleChatThreadPicker: () => void;
createNewChatConversation: () => void;
pilotRoleBadge: (role: PilotMessage["role"]) => string;
pilotRoleName: (role: PilotMessage["role"]) => string;
formatPilotStamp: (iso?: string) => string;
summarizeChangeActions: (items: PilotMessage["changeItems"] | null | undefined) => {
created: number;
updated: number;
deleted: number;
};
summarizeChangeEntities: (
items: PilotMessage["changeItems"] | null | undefined,
) => Array<{ entity: string; count: number }>;
openChangeReview: (changeSetId: string, step?: number, push?: boolean) => void;
togglePilotLiveLogsExpanded: () => void;
closeChatThreadPicker: () => void;
switchChatConversation: (id: string) => void;
formatChatThreadMeta: (conversation: ChatConversation) => string;
archiveChatConversation: (id: string) => void;
handlePilotComposerEnter: (event: KeyboardEvent) => void;
onPilotInput: (value: string) => void;
setPilotWaveContainerRef: (element: HTMLDivElement | null) => void;
toggleContextPicker: () => void;
removeContextScope: (scope: string) => void;
togglePilotRecording: () => void;
handlePilotSendAction: () => void;
}>();
</script>
<template>
<aside class="pilot-shell min-h-0 border-r border-base-300">
<div class="flex h-full min-h-0 flex-col p-0">
<div class="pilot-header">
<div>
<h2 class="text-sm font-semibold text-white/75">{{ pilotHeaderText }}</h2>
</div>
</div>
<div class="pilot-threads">
<div class="flex w-full items-center justify-between gap-2">
<button
class="btn btn-ghost btn-xs h-7 min-h-7 max-w-[228px] justify-start px-1 text-xs font-medium text-white/90 hover:bg-white/10"
:disabled="chatSwitching || chatThreadsLoading || chatConversations.length === 0"
:title="authMe?.conversation?.title || 'Thread'"
@click="toggleChatThreadPicker"
>
<span class="truncate">{{ authMe?.conversation?.title || "Thread" }}</span>
<svg viewBox="0 0 20 20" class="ml-1 h-3.5 w-3.5 fill-current opacity-80">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
<button
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-white/85 hover:bg-white/10"
:disabled="chatCreating"
title="New chat"
@click="createNewChatConversation"
>
{{ chatCreating ? "…" : "+" }}
</button>
</div>
</div>
<div class="pilot-stream-wrap min-h-0 flex-1">
<div class="pilot-timeline min-h-0 h-full overflow-y-auto">
<div
v-for="message in renderedPilotMessages"
:key="message.id"
class="pilot-row"
>
<div class="pilot-avatar" :class="message.role === 'user' ? 'pilot-avatar-user' : ''">
{{ pilotRoleBadge(message.role) }}
</div>
<div class="pilot-body">
<div class="pilot-meta">
<span class="pilot-author">{{ pilotRoleName(message.role) }}</span>
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
</div>
<div v-if="message.messageKind === 'change_set_summary'" class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
<p class="text-xs font-semibold text-amber-100">
{{ message.changeSummary || "Technical change summary" }}
</p>
<div class="mt-2 flex flex-wrap gap-1.5 text-[10px]">
<span class="rounded border border-white/25 px-2 py-0.5 text-white/85">
total {{ message.changeItems?.length || 0 }}
</span>
<span class="rounded border border-white/25 px-2 py-0.5 text-white/85">
created {{ summarizeChangeActions(message.changeItems).created }}
</span>
<span class="rounded border border-white/25 px-2 py-0.5 text-white/85">
updated {{ summarizeChangeActions(message.changeItems).updated }}
</span>
<span class="rounded border border-white/25 px-2 py-0.5 text-white/85">
archived {{ summarizeChangeActions(message.changeItems).deleted }}
</span>
</div>
<div v-if="summarizeChangeEntities(message.changeItems).length" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="row in summarizeChangeEntities(message.changeItems)"
:key="`entity-summary-${message.id}-${row.entity}`"
class="rounded border border-white/20 px-2 py-0.5 text-[10px] text-white/75"
>
{{ row.entity }}: {{ row.count }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
v-if="message.changeSetId"
class="btn btn-xs btn-outline"
@click="openChangeReview(message.changeSetId, 0, true)"
>
Review Changes
</button>
<span class="text-[10px] uppercase tracking-wide text-amber-100/80">
status: {{ message.changeStatus || "pending" }}
</span>
</div>
</div>
<div v-else class="pilot-message-text">
{{ message.text }}
</div>
</div>
</div>
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
<div class="pilot-stream-head">
<p v-if="!pilotLiveLogsExpanded && pilotLiveLogHiddenCount > 0" class="pilot-stream-caption">
Showing last {{ pilotVisibleLogCount }} steps
</p>
<button
v-if="pilotLiveLogHiddenCount > 0 || pilotLiveLogsExpanded"
type="button"
class="pilot-stream-toggle"
@click="togglePilotLiveLogsExpanded"
>
{{ pilotLiveLogsExpanded ? "Show less" : `Show all (+${pilotLiveLogHiddenCount})` }}
</button>
</div>
<p
v-for="log in pilotVisibleLiveLogs"
:key="`pilot-log-${log.id}`"
class="pilot-stream-line"
:class="log.id === pilotLiveLogs[pilotLiveLogs.length - 1]?.id ? 'pilot-stream-line-current' : ''"
>
{{ log.text }}
</p>
</div>
</div>
<div v-if="chatThreadPickerOpen" class="pilot-thread-overlay">
<div class="mb-2 flex items-center justify-between">
<p class="text-[11px] font-semibold uppercase tracking-wide text-white/60">Threads</p>
<button class="btn btn-ghost btn-xs btn-square text-white/70 hover:bg-white/10" title="Close" @click="closeChatThreadPicker">
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
<path d="M11.06 10 15.53 5.53a.75.75 0 1 0-1.06-1.06L10 8.94 5.53 4.47a.75.75 0 0 0-1.06 1.06L8.94 10l-4.47 4.47a.75.75 0 1 0 1.06 1.06L10 11.06l4.47 4.47a.75.75 0 0 0 1.06-1.06z" />
</svg>
</button>
</div>
<div class="max-h-full space-y-1 overflow-y-auto pr-1">
<div
v-for="thread in chatConversations"
:key="`thread-row-${thread.id}`"
class="flex items-center gap-1 rounded-md"
>
<button
class="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left transition hover:bg-white/10"
:class="selectedChatId === thread.id ? 'bg-white/12' : ''"
:disabled="chatSwitching || chatArchivingId === thread.id"
@click="switchChatConversation(thread.id)"
>
<p class="truncate text-xs font-medium text-white">{{ thread.title }}</p>
<p class="truncate text-[11px] text-white/55">
{{ thread.lastMessageText || "No messages yet" }} · {{ formatChatThreadMeta(thread) }}
</p>
</button>
<button
class="btn btn-ghost btn-xs btn-square text-white/55 hover:bg-white/10 hover:text-red-300"
:disabled="chatSwitching || chatArchivingId === thread.id || chatConversations.length <= 1"
title="Archive thread"
@click="archiveChatConversation(thread.id)"
>
<span v-if="chatArchivingId === thread.id" class="loading loading-spinner loading-xs" />
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20.54 5.23 19 3H5L3.46 5.23A2 2 0 0 0 3 6.36V8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.36a2 2 0 0 0-.46-1.13M5.16 5h13.68l.5.73A.5.5 0 0 1 19.5 6H4.5a.5.5 0 0 1-.34-.27zM6 12h12v6a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="pilot-input-wrap">
<div class="pilot-input-shell">
<textarea
:value="pilotInput"
class="pilot-input-textarea"
:placeholder="pilotRecording ? 'Recording... speak, then press mic to fill or send to submit' : 'Type a message for Pilot...'"
@input="onPilotInput(($event.target as HTMLTextAreaElement | null)?.value ?? '')"
@keydown.enter="handlePilotComposerEnter"
/>
<div v-if="pilotRecording" class="pilot-meter">
<div :ref="setPilotWaveContainerRef" class="pilot-wave-canvas" />
</div>
<div v-if="!pilotRecording" class="pilot-input-context">
<button
v-if="contextScopeChips.length === 0"
class="context-pipette-trigger"
:class="contextPickerEnabled ? 'context-pipette-active' : ''"
:disabled="pilotTranscribing || pilotSending"
aria-label="Контекстная пипетка"
:title="contextPickerEnabled ? 'Выключить пипетку' : 'Включить пипетку контекста'"
@click="toggleContextPicker"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M19.29 4.71a1 1 0 0 0-1.42 0l-2.58 2.58-1.17-1.17a1 1 0 0 0-1.41 0l-1.42 1.42a1 1 0 0 0 0 1.41l.59.59-5.3 5.3a2 2 0 0 0-.53.92l-.86 3.43a1 1 0 0 0 1.21 1.21l3.43-.86a2 2 0 0 0 .92-.53l5.3-5.3.59.59a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0 0-1.41l-1.17-1.17 2.58-2.58a1 1 0 0 0 0-1.42z" />
</svg>
</button>
<div v-else class="pilot-context-chips">
<button
v-for="chip in contextScopeChips"
:key="`context-chip-${chip.scope}`"
type="button"
class="context-pipette-chip"
@click="removeContextScope(chip.scope)"
>
{{ chip.label }}
<span class="opacity-70">×</span>
</button>
</div>
</div>
<div class="pilot-input-actions">
<button
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
:class="pilotRecording ? 'pilot-mic-active' : ''"
:disabled="!pilotMicSupported || pilotTranscribing || pilotSending"
:title="pilotRecording ? 'Stop and insert transcript' : 'Voice input'"
@click="togglePilotRecording"
>
<svg v-if="!pilotTranscribing" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
</svg>
<span v-else class="loading loading-spinner loading-xs" />
</button>
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="pilotTranscribing || pilotSending || (!pilotRecording && !String(pilotInput ?? '').trim())"
:title="pilotRecording ? 'Transcribe and send' : 'Send message'"
@click="handlePilotSendAction"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="pilotSending ? 'opacity-50' : ''">
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
</svg>
</button>
</div>
</div>
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
</div>
</div>
</aside>
</template>
<style scoped>
.pilot-shell {
background:
radial-gradient(circle at 10% -10%, rgba(124, 144, 255, 0.25), transparent 40%),
radial-gradient(circle at 85% 110%, rgba(88, 101, 242, 0.2), transparent 45%),
#151821;
color: #f5f7ff;
}
.pilot-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 10, 16, 0.2);
}
.pilot-threads {
padding: 10px 10px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-timeline {
padding: 10px 8px;
}
.pilot-stream-wrap {
position: relative;
}
.pilot-thread-overlay {
position: absolute;
inset: 0;
z-index: 20;
padding: 10px 8px;
background:
linear-gradient(180deg, rgba(15, 18, 28, 0.96), rgba(15, 18, 28, 0.92)),
rgba(15, 18, 28, 0.9);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 6px;
border-radius: 10px;
}
.pilot-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.pilot-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
width: 30px;
height: 30px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
color: #e6ebff;
background: linear-gradient(135deg, #5865f2, #7c90ff);
}
.pilot-avatar-user {
background: linear-gradient(135deg, #2a9d8f, #38b2a7);
}
.pilot-body {
min-width: 0;
width: 100%;
}
.pilot-meta {
display: flex;
align-items: center;
gap: 8px;
}
.pilot-author {
font-size: 13px;
font-weight: 700;
color: #f8f9ff;
}
.pilot-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.pilot-message-text {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
color: rgba(255, 255, 255, 0.92);
}
.pilot-input-wrap {
display: grid;
gap: 6px;
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
}
.pilot-input-shell {
position: relative;
}
.pilot-input-textarea {
width: 100%;
min-height: 96px;
resize: none;
border-radius: 0;
border: 0;
background: transparent;
color: #f5f7ff;
padding: 10px 88px 36px 12px;
font-size: 13px;
line-height: 1.4;
}
.pilot-input-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.pilot-input-textarea:focus {
outline: none;
box-shadow: none;
}
.pilot-input-actions {
position: absolute;
right: 10px;
bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.pilot-input-context {
position: absolute;
left: 10px;
bottom: 8px;
right: 96px;
display: flex;
align-items: center;
min-height: 24px;
overflow: hidden;
}
.context-pipette-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.06);
color: rgba(245, 247, 255, 0.94);
padding: 0;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-trigger:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
.context-pipette-active {
border-color: color-mix(in oklab, var(--color-primary) 72%, transparent);
background: color-mix(in oklab, var(--color-primary) 24%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 38%, transparent) inset;
}
.pilot-context-chips {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
overflow-x: auto;
padding-bottom: 1px;
}
.pilot-context-chips::-webkit-scrollbar {
height: 3px;
}
.pilot-context-chips::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.context-pipette-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
color: rgba(245, 247, 255, 0.95);
padding: 4px 9px;
font-size: 10px;
line-height: 1;
white-space: nowrap;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-chip:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 20%, transparent);
}
.pilot-meter {
position: absolute;
left: 12px;
right: 88px;
bottom: 9px;
height: 22px;
}
.pilot-wave-canvas {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
}
.pilot-wave-canvas :deep(wave) {
display: block;
height: 100% !important;
}
.pilot-wave-canvas :deep(canvas) {
height: 100% !important;
}
.pilot-mic-active {
border-color: rgba(255, 95, 95, 0.8) !important;
background: rgba(255, 95, 95, 0.16) !important;
color: #ffd9d9 !important;
}
.pilot-mic-error {
margin: 0;
font-size: 11px;
color: rgba(255, 160, 160, 0.92);
}
.pilot-stream-status {
margin-top: 8px;
padding: 2px 4px 8px;
display: grid;
gap: 3px;
}
.pilot-stream-head {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 2px;
}
.pilot-stream-caption {
margin: 0;
text-align: center;
font-size: 10px;
line-height: 1.3;
color: rgba(174, 185, 223, 0.72);
}
.pilot-stream-toggle {
border: 1px solid rgba(164, 179, 230, 0.35);
background: rgba(25, 33, 56, 0.45);
color: rgba(229, 235, 255, 0.92);
border-radius: 999px;
font-size: 10px;
line-height: 1.2;
padding: 3px 8px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.pilot-stream-toggle:hover {
border-color: rgba(180, 194, 240, 0.6);
background: rgba(35, 45, 72, 0.7);
}
.pilot-stream-line {
margin: 0;
text-align: center;
font-size: 11px;
line-height: 1.35;
color: rgba(189, 199, 233, 0.72);
}
.pilot-stream-line-current {
color: rgba(234, 239, 255, 0.95);
}
</style>