refactor(frontend): extract pilot sidebar into workspace component

This commit is contained in:
Ruslan Bakiev
2026-02-23 11:30:49 +07:00
parent 2b72d42956
commit d5f7280297
2 changed files with 685 additions and 557 deletions

View File

@@ -4,6 +4,7 @@ import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue"
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocumentsPanel.vue";
import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue";
import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.vue";
import CrmChangeReviewOverlay from "~~/app/components/workspace/review/CrmChangeReviewOverlay.vue";
import meQuery from "~~/graphql/operations/me.graphql?raw";
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
@@ -424,6 +425,9 @@ const pilotTranscribing = ref(false);
const pilotMicSupported = ref(false);
const pilotMicError = ref<string | null>(null);
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
function setPilotWaveContainerRef(element: HTMLDivElement | null) {
pilotWaveContainer.value = element;
}
const livePilotUserText = ref("");
const livePilotAssistantText = ref("");
const contextPickerEnabled = ref(false);
@@ -4750,254 +4754,50 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<template v-else>
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
<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 overflow-x-auto">
<table class="w-full min-w-[340px] text-left text-[11px] text-white/85">
<thead>
<tr class="text-white/60">
<th class="py-1 pr-2 font-medium">Metric</th>
<th class="py-1 pr-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-1 pr-2">Total changes</td>
<td class="py-1 pr-2">{{ message.changeItems?.length || 0 }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Created</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).created }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Updated</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).updated }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Archived</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).deleted }}</td>
</tr>
</tbody>
</table>
</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
v-model="pilotInput"
class="pilot-input-textarea"
:placeholder="pilotRecording ? 'Recording... speak, then press mic to fill or send to submit' : 'Type a message for Pilot...'"
@keydown.enter="handlePilotComposerEnter"
/>
<div v-if="pilotRecording" class="pilot-meter">
<div ref="pilotWaveContainer" 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 && !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>
<CrmPilotSidebar
:pilot-header-text="pilotHeaderText"
:chat-switching="chatSwitching"
:chat-threads-loading="chatThreadsLoading"
:chat-conversations="chatConversations"
:auth-me="authMe"
:chat-creating="chatCreating"
:rendered-pilot-messages="renderedPilotMessages"
:pilot-live-logs="pilotLiveLogs"
:pilot-live-logs-expanded="pilotLiveLogsExpanded"
:pilot-live-log-hidden-count="pilotLiveLogHiddenCount"
:pilot-visible-log-count="pilotVisibleLogCount"
:pilot-visible-live-logs="pilotVisibleLiveLogs"
:chat-thread-picker-open="chatThreadPickerOpen"
:selected-chat-id="selectedChatId"
:chat-archiving-id="chatArchivingId"
:pilot-input-ref="pilotInput"
:pilot-recording="pilotRecording"
:context-scope-chips="contextScopeChips"
:context-picker-enabled="contextPickerEnabled"
:pilot-transcribing="pilotTranscribing"
:pilot-sending="pilotSending"
:pilot-mic-supported="pilotMicSupported"
:pilot-mic-error="pilotMicError"
:toggle-chat-thread-picker="toggleChatThreadPicker"
:create-new-chat-conversation="createNewChatConversation"
:pilot-role-badge="pilotRoleBadge"
:pilot-role-name="pilotRoleName"
:format-pilot-stamp="formatPilotStamp"
:summarize-change-actions="summarizeChangeActions"
:summarize-change-entities="summarizeChangeEntities"
:open-change-review="openChangeReview"
:toggle-pilot-live-logs-expanded="togglePilotLiveLogsExpanded"
:close-chat-thread-picker="closeChatThreadPicker"
:switch-chat-conversation="switchChatConversation"
:format-chat-thread-meta="formatChatThreadMeta"
:archive-chat-conversation="archiveChatConversation"
:handle-pilot-composer-enter="handlePilotComposerEnter"
:set-pilot-wave-container-ref="setPilotWaveContainerRef"
:toggle-context-picker="toggleContextPicker"
:remove-context-scope="removeContextScope"
:toggle-pilot-recording="togglePilotRecording"
:handle-pilot-send-action="handlePilotSendAction"
/>
<main class="relative min-h-0 bg-base-100">
<div class="flex h-full min-h-0 flex-col">
@@ -6607,265 +6407,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
}
}
.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);
}
.comm-input-wrap {
display: grid;
@@ -7009,56 +6550,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
padding: 14px;
}
.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);
}
.feed-chart-wrap {
display: flex;