Files
clientsflow/frontend/app/components/workspace/CrmWorkspaceApp.vue
Ruslan Bakiev 227030b9ae feat(calendar): replace CSS-transform zoom with GSAP flying-rect animation and scope data to year
- Add CalendarDateRange input to GraphQL schema; server resolver now accepts from/to params
- Frontend query sends year-scoped date range variables reactively
- Rewrite zoom-in/zoom-out animations using GSAP flying-rect overlay (650ms vs 2400ms)
- Add flying-rect element to CrmCalendarPanel with proper CSS
- Remove old calendarSceneTransformStyle CSS-transition approach
- Add calendarKillTweens cleanup in onBeforeUnmount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:41:35 +07:00

6000 lines
210 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">
import gsap from "gsap";
import { nextTick, onBeforeUnmount, onMounted } from "vue";
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
import CrmCommunicationsContextSidebar from "~~/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue";
import CrmCommunicationsListSidebar from "~~/app/components/workspace/communications/CrmCommunicationsListSidebar.vue";
import CrmVoiceDictationButton from "~~/app/components/workspace/communications/CrmVoiceDictationButton.client.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 { useQuery, useMutation } from "@vue/apollo-composable";
import {
MeQueryDocument,
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
ContactsQueryDocument,
CommunicationsQueryDocument,
ContactInboxesQueryDocument,
CalendarQueryDocument,
DealsQueryDocument,
FeedQueryDocument,
PinsQueryDocument,
DocumentsQueryDocument,
GetClientTimelineQueryDocument,
LogoutMutationDocument,
LogPilotNoteMutationDocument,
CreateCalendarEventMutationDocument,
ArchiveCalendarEventMutationDocument,
CreateCommunicationMutationDocument,
CreateWorkspaceDocumentDocument,
DeleteWorkspaceDocumentDocument,
UpdateCommunicationTranscriptMutationDocument,
UpdateFeedDecisionMutationDocument,
CreateChatConversationMutationDocument,
SelectChatConversationMutationDocument,
ArchiveChatConversationMutationDocument,
ToggleContactPinMutationDocument,
SetContactInboxHiddenDocument,
ConfirmLatestChangeSetMutationDocument,
RollbackLatestChangeSetMutationDocument,
RollbackChangeSetItemsMutationDocument,
} from "~~/graphql/generated";
import {
buildContactDocumentScope,
formatDocumentScope,
isDocumentLinkedToContact,
} from "~~/app/composables/useWorkspaceDocuments";
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~~/app/composables/useVoiceTranscription";
import { Chat as AiChat } from "@ai-sdk/vue";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
type TabId = "communications" | "documents";
type CalendarView = "day" | "week" | "month" | "year" | "agenda";
type SortMode = "name" | "lastContact";
type PeopleLeftMode = "contacts" | "calendar";
type PeopleSortMode = "name" | "lastContact";
type PeopleVisibilityMode = "all" | "hidden";
type DocumentSortMode = "updatedAt" | "title" | "owner";
type FeedCard = {
id: string;
at: string;
contact: string;
text: string;
proposal: {
title: string;
details: string[];
key: "create_followup" | "open_comm" | "call" | "draft_message" | "run_summary" | "prepare_question";
};
decision: "pending" | "accepted" | "rejected";
decisionNote?: string;
};
type Contact = {
id: string;
name: string;
avatar: string;
channels: string[];
lastContactAt: string;
description: string;
};
type CalendarEvent = {
id: string;
title: string;
start: string;
end: string;
contact: string;
note: string;
isArchived: boolean;
createdAt: string;
archiveNote: string;
archivedAt: string;
};
type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
type CommItem = {
id: string;
at: string;
contact: string;
contactInboxId: string;
sourceExternalId: string;
sourceTitle: string;
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
kind: "message" | "call";
direction: "in" | "out";
text: string;
audioUrl?: string;
duration?: string;
waveform?: number[];
transcript?: string[];
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
};
type ContactInbox = {
id: string;
contactId: string;
contactName: string;
channel: CommItem["channel"];
sourceExternalId: string;
title: string;
isHidden: boolean;
lastMessageAt: string;
updatedAt: string;
};
type CommPin = {
id: string;
contact: string;
text: string;
};
type Deal = {
id: string;
contact: string;
title: string;
stage: string;
amount: string;
nextStep: string;
summary: string;
currentStepId: string;
steps: DealStep[];
};
type DealStep = {
id: string;
title: string;
description: string;
status: "todo" | "in_progress" | "done" | "blocked" | string;
dueAt: string;
order: number;
completedAt: string;
};
type WorkspaceDocument = {
id: string;
title: string;
type: "Regulation" | "Playbook" | "Policy" | "Template";
owner: string;
scope: string;
updatedAt: string;
summary: string;
body: string;
};
type ClientTimelineItem = {
id: string;
contactId: string;
contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
contentId: string;
datetime: string;
message?: CommItem | null;
calendarEvent?: CalendarEvent | null;
recommendation?: FeedCard | null;
document?: WorkspaceDocument | null;
};
const selectedTab = ref<TabId>("communications");
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
function safeTrim(value: unknown) {
return String(value ?? "").trim();
}
function dayKey(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatDay(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso));
}
function formatTime(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function formatThreadTime(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
.format(new Date(iso))
.replace(":", ".");
}
function formatStamp(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function atOffset(days: number, hour: number, minute: number) {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
function inMinutes(minutes: number) {
const d = new Date();
d.setMinutes(d.getMinutes() + minutes, 0, 0);
return d.toISOString();
}
function endAfter(startIso: string, minutes: number) {
const d = new Date(startIso);
d.setMinutes(d.getMinutes() + minutes);
return d.toISOString();
}
function isEventFinalStatus(isArchived: boolean) {
return Boolean(isArchived);
}
function eventPreDueAt(event: CalendarEvent) {
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
}
function eventDueAt(event: CalendarEvent) {
return event.start;
}
function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
if (event.isArchived) return "closed";
const dueMs = new Date(eventDueAt(event)).getTime();
const preDueMs = new Date(eventPreDueAt(event)).getTime();
if (nowMs >= dueMs) return "awaiting_outcome";
if (nowMs >= preDueMs) return "due_soon";
return "scheduled";
}
function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
if (phase === "scheduled") return event.createdAt || event.start;
if (phase === "due_soon") return eventPreDueAt(event);
return eventDueAt(event);
}
function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
if (event.isArchived) return "Archived";
const diffMs = new Date(event.start).getTime() - nowMs;
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
const abs = Math.abs(diffMs);
if (diffMs >= 0) {
if (abs >= dayMs) {
const days = Math.round(abs / dayMs);
return `Event in ${days} day${days === 1 ? "" : "s"}`;
}
if (abs >= hourMs) {
const hours = Math.round(abs / hourMs);
return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
}
const minutes = Math.max(1, Math.round(abs / minuteMs));
return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
if (abs >= dayMs) {
const days = Math.round(abs / dayMs);
return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
}
if (abs >= hourMs) {
const hours = Math.round(abs / hourMs);
return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
}
const minutes = Math.max(1, Math.round(abs / minuteMs));
return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
function eventPhaseToneClass(phase: EventLifecyclePhase) {
if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
if (phase === "due_soon") return "border-info/50 bg-info/10";
if (phase === "closed") return "border-success/40 bg-success/10";
return "border-base-300 bg-base-100";
}
function toInputDate(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function toInputTime(date: Date) {
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
function roundToNextQuarter(date = new Date()) {
const d = new Date(date);
d.setSeconds(0, 0);
const minutes = d.getMinutes();
const rounded = Math.ceil(minutes / 15) * 15;
if (rounded >= 60) {
d.setHours(d.getHours() + 1, 0, 0, 0);
} else {
d.setMinutes(rounded, 0, 0);
}
return d;
}
function roundToPrevQuarter(date = new Date()) {
const d = new Date(date);
d.setSeconds(0, 0);
const minutes = d.getMinutes();
const rounded = Math.floor(minutes / 15) * 15;
d.setMinutes(rounded, 0, 0);
return d;
}
const feedCards = ref<FeedCard[]>([]);
const contacts = ref<Contact[]>([]);
const calendarEvents = ref<CalendarEvent[]>([]);
const commItems = ref<CommItem[]>([]);
const contactInboxes = ref<ContactInbox[]>([]);
const clientTimelineItems = ref<ClientTimelineItem[]>([]);
const commPins = ref<CommPin[]>([]);
const deals = ref<Deal[]>([]);
const documents = ref<WorkspaceDocument[]>([]);
type PilotMessage = {
id: string;
role: "user" | "assistant" | "system";
text: string;
messageKind?: string | null;
requestId?: string | null;
eventType?: string | null;
phase?: string | null;
transient?: boolean | null;
thinking?: string[] | null;
tools?: string[] | null;
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}> | null;
changeSetId?: string | null;
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
changeSummary?: string | null;
changeItems?: Array<{
id: string;
entity: string;
entityId?: string | null;
action: string;
title: string;
before: string;
after: string;
rolledBack?: boolean;
}> | null;
createdAt?: string;
_live?: boolean;
};
type PilotChangeItem = NonNullable<PilotMessage["changeItems"]>[number];
type ContextScope = "summary" | "deal" | "message" | "calendar";
type PilotContextPayload = {
scopes: ContextScope[];
summary?: {
contactId: string;
name: string;
};
deal?: {
dealId: string;
title: string;
contact: string;
};
message?: {
contactId?: string;
contact?: string;
intent: "add_message_or_reminder";
};
calendar?: {
view: CalendarView;
period: string;
selectedDateKey: string;
focusedEventId?: string;
eventIds: string[];
};
};
type ChatConversation = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
lastMessageAt?: string | null;
lastMessageText?: string | null;
};
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const pilotRecording = ref(false);
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);
const contextScopes = ref<ContextScope[]>([]);
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
const pilotLiveLogsExpanded = ref(false);
const pilotLiveLogHiddenCount = computed(() => {
const hidden = pilotLiveLogs.value.length - PILOT_LIVE_LOGS_PREVIEW_LIMIT;
return hidden > 0 ? hidden : 0;
});
const pilotVisibleLiveLogs = computed(() => {
if (pilotLiveLogsExpanded.value || pilotLiveLogHiddenCount.value === 0) return pilotLiveLogs.value;
return pilotLiveLogs.value.slice(-PILOT_LIVE_LOGS_PREVIEW_LIMIT);
});
const pilotVisibleLogCount = computed(() =>
Math.min(pilotLiveLogs.value.length, PILOT_LIVE_LOGS_PREVIEW_LIMIT),
);
function togglePilotLiveLogsExpanded() {
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
}
function toggleContextPicker() {
contextPickerEnabled.value = !contextPickerEnabled.value;
}
function hasContextScope(scope: ContextScope) {
return contextScopes.value.includes(scope);
}
function toggleContextScope(scope: ContextScope) {
if (!contextPickerEnabled.value) return;
if (hasContextScope(scope)) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
return;
}
contextScopes.value = [...contextScopes.value, scope];
}
function removeContextScope(scope: ContextScope) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
}
let pilotMediaRecorder: MediaRecorder | null = null;
let pilotRecorderStream: MediaStream | null = null;
let pilotRecordingChunks: Blob[] = [];
let pilotRecorderMimeType = "audio/webm";
let pilotRecordingFinishMode: "fill" | "send" = "fill";
let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
let pilotWaveSurfer: any = null;
let pilotWaveRecordPlugin: any = null;
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
const commCallWaveHosts = new Map<string, HTMLDivElement>();
const commCallWaveSurfers = new Map<string, any>();
const commCallPlayableById = ref<Record<string, boolean>>({});
const commCallPlayingById = ref<Record<string, boolean>>({});
const callTranscriptOpen = ref<Record<string, boolean>>({});
const callTranscriptLoading = ref<Record<string, boolean>>({});
const callTranscriptText = ref<Record<string, string>>({});
const callTranscriptError = ref<Record<string, string>>({});
const pilotChat = new AiChat<UIMessage>({
transport: new DefaultChatTransport({
api: "/api/pilot-chat",
}),
onData: (part: any) => {
if (part?.type !== "data-agent-log") return;
const text = String(part?.data?.text ?? "").trim();
if (!text) return;
const at = String(part?.data?.at ?? new Date().toISOString());
pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text, at }];
},
onFinish: async () => {
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([refetchChatMessages(), refetchChatConversations(), refetchAllCrmQueries()]);
},
onError: () => {
if (livePilotUserText.value) {
pilotInput.value = livePilotUserText.value;
}
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
},
});
const authMe = ref<{
user: { id: string; phone: string; name: string };
team: { id: string; name: string };
conversation: { id: string; title: string };
} | null>(
null,
);
const chatConversations = ref<ChatConversation[]>([]);
const chatThreadsLoading = ref(false);
const chatSwitching = ref(false);
const chatCreating = ref(false);
const chatArchivingId = ref("");
const chatThreadPickerOpen = ref(false);
const commPinToggling = ref(false);
const selectedChatId = ref("");
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
const lifecycleNowMs = ref(Date.now());
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
let clientTimelineRequestToken = 0;
// ---------------------------------------------------------------------------
// Apollo Queries
// ---------------------------------------------------------------------------
const apolloAuthReady = computed(() => !!authMe.value);
const { result: meResult, refetch: refetchMe, loading: meLoading } = useQuery(
MeQueryDocument,
null,
{ fetchPolicy: "network-only" },
);
const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
ChatMessagesQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
ChatConversationsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: contactsResult, refetch: refetchContacts } = useQuery(
ContactsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: communicationsResult, refetch: refetchCommunications } = useQuery(
CommunicationsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
ContactInboxesQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const calendarQueryYear = ref(new Date().getFullYear());
const calendarQueryVars = computed(() => ({
from: new Date(calendarQueryYear.value, 0, 1).toISOString(),
to: new Date(calendarQueryYear.value + 1, 0, 1).toISOString(),
}));
const { result: calendarResult, refetch: refetchCalendar } = useQuery(
CalendarQueryDocument,
calendarQueryVars,
{ enabled: apolloAuthReady },
);
const { result: dealsResult, refetch: refetchDeals } = useQuery(
DealsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: feedResult, refetch: refetchFeed } = useQuery(
FeedQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: pinsResult, refetch: refetchPins } = useQuery(
PinsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
DocumentsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const timelineContactId = ref("");
const timelineLimit = ref(500);
const { result: timelineResult, refetch: refetchTimeline } = useQuery(
GetClientTimelineQueryDocument,
() => ({ contactId: timelineContactId.value, limit: timelineLimit.value }),
{ enabled: computed(() => !!timelineContactId.value && apolloAuthReady.value) },
);
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const allCrmQueryDocs = [
{ query: ContactsQueryDocument },
{ query: CommunicationsQueryDocument },
{ query: ContactInboxesQueryDocument },
{ query: CalendarQueryDocument },
{ query: DealsQueryDocument },
{ query: FeedQueryDocument },
{ query: PinsQueryDocument },
{ query: DocumentsQueryDocument },
];
const { mutate: doLogout } = useMutation(LogoutMutationDocument);
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
refetchQueries: [{ query: CalendarQueryDocument }],
});
const { mutate: doArchiveCalendarEvent } = useMutation(ArchiveCalendarEventMutationDocument, {
refetchQueries: [{ query: CalendarQueryDocument }],
});
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
});
const { mutate: doCreateWorkspaceDocument } = useMutation(CreateWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const { mutate: doDeleteWorkspaceDocument } = useMutation(DeleteWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }],
});
const { mutate: doUpdateFeedDecision } = useMutation(UpdateFeedDecisionMutationDocument, {
refetchQueries: [{ query: FeedQueryDocument }],
});
const { mutate: doCreateChatConversation } = useMutation(CreateChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doSelectChatConversation } = useMutation(SelectChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doArchiveChatConversation } = useMutation(ArchiveChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
refetchQueries: [{ query: PinsQueryDocument }],
});
const { mutate: doSetContactInboxHidden } = useMutation(SetContactInboxHiddenDocument, {
refetchQueries: [{ query: ContactInboxesQueryDocument }],
update: (cache, _result, { variables }) => {
if (!variables) return;
const existing = cache.readQuery({ query: ContactInboxesQueryDocument }) as { contactInboxes?: ContactInbox[] } | null;
if (!existing?.contactInboxes) return;
cache.writeQuery({
query: ContactInboxesQueryDocument,
data: {
contactInboxes: existing.contactInboxes.map((inbox) =>
inbox.id === variables.inboxId ? { ...inbox, isHidden: variables.hidden } : inbox,
),
},
});
},
});
const { mutate: doConfirmLatestChangeSet } = useMutation(ConfirmLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }],
});
const { mutate: doRollbackLatestChangeSet } = useMutation(RollbackLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
const { mutate: doRollbackChangeSetItems } = useMutation(RollbackChangeSetItemsMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
// ---------------------------------------------------------------------------
// Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
// ---------------------------------------------------------------------------
function syncPilotChatFromHistoryBridge(messages: PilotMessage[]) {
// forward-declared; actual function is defined later
syncPilotChatFromHistory(messages);
}
watch(() => meResult.value?.me, (me) => {
if (me) authMe.value = me as typeof authMe.value;
}, { immediate: true });
watch(() => chatMessagesResult.value?.chatMessages, (v) => {
if (v) {
pilotMessages.value = v as PilotMessage[];
syncPilotChatFromHistoryBridge(pilotMessages.value);
}
}, { immediate: true });
watch(() => chatConversationsResult.value?.chatConversations, (v) => {
if (v) chatConversations.value = v as ChatConversation[];
}, { immediate: true });
watch(
[() => contactsResult.value?.contacts, () => communicationsResult.value?.communications],
([rawContacts, rawComms]) => {
if (!rawContacts) return;
const contactsList = [...rawContacts] as Contact[];
const commsList = (rawComms ?? []) as CommItem[];
const byName = new Map<string, Set<string>>();
for (const item of commsList) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contactsList.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? c.channels ?? []),
}));
commItems.value = commsList;
},
{ immediate: true },
);
watch(() => contactInboxesResult.value?.contactInboxes, (v) => {
if (v) contactInboxes.value = v as ContactInbox[];
}, { immediate: true });
watch(() => calendarResult.value?.calendar, (v) => {
if (v) calendarEvents.value = v as CalendarEvent[];
}, { immediate: true });
watch(() => dealsResult.value?.deals, (v) => {
if (v) deals.value = v as Deal[];
}, { immediate: true });
watch(() => feedResult.value?.feed, (v) => {
if (v) feedCards.value = v as FeedCard[];
}, { immediate: true });
watch(() => pinsResult.value?.pins, (v) => {
if (v) commPins.value = v as CommPin[];
}, { immediate: true });
watch(() => documentsResult.value?.documents, (v) => {
if (v) documents.value = v as WorkspaceDocument[];
}, { immediate: true });
watch(() => timelineResult.value?.getClientTimeline, (v) => {
if (v) clientTimelineItems.value = v as ClientTimelineItem[];
}, { immediate: true });
// ---------------------------------------------------------------------------
// Refetch helpers (replace old gqlFetch-based functions)
// ---------------------------------------------------------------------------
async function refetchAllCrmQueries() {
await Promise.all([
refetchContacts(),
refetchCommunications(),
refetchContactInboxes(),
refetchCalendar(),
refetchDeals(),
refetchFeed(),
refetchPins(),
refetchDocuments(),
]);
await refreshSelectedClientTimeline();
}
watch(
() => pilotLiveLogs.value.length,
(len) => {
if (len === 0 || len <= PILOT_LIVE_LOGS_PREVIEW_LIMIT) {
pilotLiveLogsExpanded.value = false;
}
},
);
watch(
() => authMe.value?.conversation.id,
(id) => {
if (id) selectedChatId.value = id;
},
{ immediate: true },
);
function pilotRoleName(role: PilotMessage["role"]) {
if (role === "user") return authMe.value?.user.name ?? "You";
if (role === "system") return "Agent status";
return "Pilot";
}
function pilotRoleBadge(role: PilotMessage["role"]) {
if (role === "user") return "You";
if (role === "system") return "...";
return "AI";
}
function summarizeChangeActions(items: PilotMessage["changeItems"] | null | undefined) {
const totals = { created: 0, updated: 0, deleted: 0 };
for (const item of items ?? []) {
if (item.action === "created") totals.created += 1;
else if (item.action === "updated") totals.updated += 1;
else if (item.action === "deleted") totals.deleted += 1;
}
return totals;
}
function summarizeChangeEntities(items: PilotMessage["changeItems"] | null | undefined) {
const map = new Map<string, number>();
for (const item of items ?? []) {
const key = item.entity || "unknown";
map.set(key, (map.get(key) ?? 0) + 1);
}
return [...map.entries()]
.map(([entity, count]) => ({ entity, count }))
.sort((a, b) => b.count - a.count);
}
function formatPilotStamp(iso?: string) {
if (!iso) return "";
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function formatChatThreadMeta(conversation: ChatConversation) {
const when = conversation.lastMessageAt ?? conversation.updatedAt ?? conversation.createdAt;
if (!when) return "";
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(when));
}
function toggleChatThreadPicker() {
if (chatSwitching.value || chatThreadsLoading.value || chatConversations.value.length === 0) return;
chatThreadPickerOpen.value = !chatThreadPickerOpen.value;
}
function closeChatThreadPicker() {
chatThreadPickerOpen.value = false;
}
const authDisplayName = computed(() => authMe.value?.user.name ?? "User");
const authInitials = computed(() => {
const parts = authDisplayName.value
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (parts.length === 0) return "U";
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("");
});
type TelegramConnectStatus =
| "not_connected"
| "pending_link"
| "pending_business_connection"
| "connected"
| "disabled"
| "no_reply_rights";
type TelegramConnectionSummary = {
businessConnectionId: string;
isEnabled: boolean | null;
canReply: boolean | null;
updatedAt: string;
};
const telegramConnectStatus = ref<TelegramConnectStatus>("not_connected");
const telegramConnectStatusLoading = ref(false);
const telegramConnectBusy = ref(false);
const telegramConnectUrl = ref("");
const telegramConnections = ref<TelegramConnectionSummary[]>([]);
const telegramConnectNotice = ref("");
const telegramStatusLabel = computed(() => {
if (telegramConnectStatusLoading.value) return "Checking";
if (telegramConnectStatus.value === "connected") return "Connected";
if (telegramConnectStatus.value === "pending_link") return "Pending link";
if (telegramConnectStatus.value === "pending_business_connection") return "Waiting business connect";
if (telegramConnectStatus.value === "disabled") return "Disabled";
if (telegramConnectStatus.value === "no_reply_rights") return "No reply rights";
return "Not connected";
});
const telegramStatusBadgeClass = computed(() => {
if (telegramConnectStatus.value === "connected") return "badge-success";
if (telegramConnectStatus.value === "pending_link" || telegramConnectStatus.value === "pending_business_connection") return "badge-warning";
if (telegramConnectStatus.value === "disabled" || telegramConnectStatus.value === "no_reply_rights") return "badge-error";
return "badge-ghost";
});
async function loadTelegramConnectStatus() {
if (!authMe.value) {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
return;
}
telegramConnectStatusLoading.value = true;
try {
const result = await $fetch<{
ok: boolean;
status: TelegramConnectStatus;
connections?: TelegramConnectionSummary[];
}>("/api/omni/telegram/business/connect/status", {
method: "GET",
});
telegramConnectStatus.value = result?.status ?? "not_connected";
telegramConnections.value = result?.connections ?? [];
} catch {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
} finally {
telegramConnectStatusLoading.value = false;
}
}
async function startTelegramBusinessConnect() {
if (telegramConnectBusy.value) return;
telegramConnectBusy.value = true;
try {
const result = await $fetch<{
ok: boolean;
status: TelegramConnectStatus;
connectUrl: string;
expiresAt: string;
}>("/api/omni/telegram/business/connect/start", { method: "POST" });
telegramConnectStatus.value = result?.status ?? "pending_link";
telegramConnectUrl.value = String(result?.connectUrl ?? "").trim();
if (telegramConnectUrl.value && process.client) {
window.location.href = telegramConnectUrl.value;
}
} catch {
telegramConnectStatus.value = "not_connected";
} finally {
telegramConnectBusy.value = false;
await loadTelegramConnectStatus();
}
}
async function completeTelegramBusinessConnectFromToken(token: string) {
const t = String(token || "").trim();
if (!t) return;
try {
const result = await $fetch<{
ok: boolean;
status: string;
businessConnectionId?: string;
}>("/api/omni/telegram/business/connect/complete", {
method: "POST",
body: { token: t },
});
if (result?.ok) {
telegramConnectStatus.value = "connected";
telegramConnectNotice.value = "Telegram успешно привязан.";
await loadTelegramConnectStatus();
return;
}
if (result?.status === "awaiting_telegram_start") {
telegramConnectNotice.value = "Сначала нажмите Start в Telegram, затем нажмите кнопку в боте снова.";
} else if (result?.status === "invalid_or_expired_token") {
telegramConnectNotice.value = "Ссылка привязки истекла. Нажмите Connect в CRM заново.";
} else {
telegramConnectNotice.value = "Не удалось завершить привязку. Запустите Connect заново.";
}
} catch {
telegramConnectNotice.value = "Ошибка завершения привязки. Попробуйте снова.";
}
}
function pilotToUiMessage(message: PilotMessage): UIMessage {
return {
id: message.id,
role: message.role,
parts: [{ type: "text", text: message.text }],
metadata: {
createdAt: message.createdAt ?? null,
},
};
}
function syncPilotChatFromHistory(messages: PilotMessage[]) {
pilotChat.messages = messages.filter((m) => m.role !== "system").map(pilotToUiMessage);
}
function normalizePilotTimeline(messages: PilotMessage[]) {
const sorted = [...messages].sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
const finalizedRequestIds = new Set(
sorted
.filter((m) => m.role === "assistant" && m.phase === "final" && m.requestId)
.map((m) => m.requestId as string),
);
const latestAssistantAt = [...sorted].reverse().find((m) => m.role === "assistant")?.createdAt ?? null;
const out: PilotMessage[] = [];
const traceIndexByRequestId = new Map<string, number>();
for (const message of sorted) {
const requestId = (message.requestId ?? "").trim();
const isTrace = message.role === "system" || message.eventType === "trace";
const isTransient = message.transient === true || isTrace;
if (isTransient) {
if (requestId && finalizedRequestIds.has(requestId)) {
continue;
}
if (!requestId && latestAssistantAt && (message.createdAt ?? "") <= latestAssistantAt) {
continue;
}
}
if (isTrace && requestId) {
const existingIdx = traceIndexByRequestId.get(requestId);
if (typeof existingIdx === "number") {
out[existingIdx] = message;
continue;
}
traceIndexByRequestId.set(requestId, out.length);
} else if (requestId) {
traceIndexByRequestId.delete(requestId);
}
out.push(message);
}
return out;
}
const renderedPilotMessages = computed<PilotMessage[]>(() => {
const items = normalizePilotTimeline(pilotMessages.value).filter((m) => m.role !== "system");
const hasPersistedLiveUser = items.some(
(m) => m.role === "user" && safeTrim(m.text) === livePilotUserText.value.trim(),
);
if (livePilotUserText.value && !hasPersistedLiveUser) {
items.push({
id: "pilot-live-user",
role: "user",
text: livePilotUserText.value,
createdAt: new Date().toISOString(),
_live: true,
});
}
if (livePilotAssistantText.value) {
items.push({
id: "pilot-live-assistant",
role: "assistant",
text: livePilotAssistantText.value,
createdAt: new Date().toISOString(),
_live: true,
});
}
return items;
});
async function loadPilotMessages() {
await refetchChatMessages();
}
async function loadChatConversations() {
chatThreadsLoading.value = true;
try {
await refetchChatConversations();
} finally {
chatThreadsLoading.value = false;
}
}
async function loadMe() {
const result = await refetchMe();
const me = result?.data?.me;
if (me) authMe.value = me as typeof authMe.value;
}
const authResolved = ref(false);
async function bootstrapSession() {
const resetAuthState = () => {
stopCrmRealtime();
authMe.value = null;
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
};
try {
await loadMe();
if (!authMe.value) {
resetAuthState();
if (process.client) {
await navigateTo("/login", { replace: true });
}
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
if (process.client) {
startCrmRealtime();
}
} catch {
resetAuthState();
if (process.client) {
await navigateTo("/login", { replace: true });
}
} finally {
authResolved.value = true;
}
}
async function createNewChatConversation() {
if (chatCreating.value) return;
chatThreadPickerOpen.value = false;
chatCreating.value = true;
try {
await doCreateChatConversation();
} finally {
chatCreating.value = false;
}
}
async function switchChatConversation(id: string) {
if (!id || chatSwitching.value || authMe.value?.conversation.id === id) return;
chatThreadPickerOpen.value = false;
chatSwitching.value = true;
try {
await doSelectChatConversation({ id });
} finally {
chatSwitching.value = false;
}
}
async function archiveChatConversation(id: string) {
if (!id || chatArchivingId.value) return;
chatArchivingId.value = id;
try {
await doArchiveChatConversation({ id });
} finally {
chatArchivingId.value = "";
}
}
async function logout() {
await doLogout();
stopCrmRealtime();
stopPilotBackgroundPolling();
authMe.value = null;
pilotMessages.value = [];
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotChat.messages = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
if (process.client) {
await navigateTo("/login", { replace: true });
}
}
async function refreshCrmData() {
await refetchAllCrmQueries();
}
async function loadClientTimeline(contactId: string, limit = 500) {
const normalizedContactId = String(contactId ?? "").trim();
if (!normalizedContactId) {
clientTimelineItems.value = [];
timelineContactId.value = "";
return;
}
timelineContactId.value = normalizedContactId;
timelineLimit.value = limit;
await refetchTimeline();
}
async function refreshSelectedClientTimeline() {
const contactId = String(selectedCommThreadId.value ?? "").trim();
if (!contactId) {
clientTimelineItems.value = [];
return;
}
await loadClientTimeline(contactId);
}
function clearCrmRealtimeReconnectTimer() {
if (!crmRealtimeReconnectTimer) return;
clearTimeout(crmRealtimeReconnectTimer);
crmRealtimeReconnectTimer = null;
}
function clearCrmRealtimeRefreshTimer() {
if (!crmRealtimeRefreshTimer) return;
clearTimeout(crmRealtimeRefreshTimer);
crmRealtimeRefreshTimer = null;
}
async function runCrmRealtimeRefresh() {
if (!authMe.value || crmRealtimeRefreshInFlight) return;
crmRealtimeRefreshInFlight = true;
try {
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
} catch {
// ignore transient realtime refresh errors
} finally {
crmRealtimeRefreshInFlight = false;
}
}
function scheduleCrmRealtimeRefresh(delayMs = 250) {
clearCrmRealtimeRefreshTimer();
crmRealtimeRefreshTimer = setTimeout(() => {
crmRealtimeRefreshTimer = null;
void runCrmRealtimeRefresh();
}, delayMs);
}
function scheduleCrmRealtimeReconnect() {
clearCrmRealtimeReconnectTimer();
const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
crmRealtimeReconnectAttempt = attempt;
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
crmRealtimeReconnectTimer = setTimeout(() => {
crmRealtimeReconnectTimer = null;
startCrmRealtime();
}, delayMs);
}
function stopCrmRealtime() {
clearCrmRealtimeReconnectTimer();
clearCrmRealtimeRefreshTimer();
if (crmRealtimeSocket) {
const socket = crmRealtimeSocket;
crmRealtimeSocket = null;
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
try {
socket.close(1000, "client stop");
} catch {
// ignore socket close errors
}
}
crmRealtimeState.value = "idle";
}
function startCrmRealtime() {
if (process.server || !authMe.value) return;
if (crmRealtimeSocket) {
const state = crmRealtimeSocket.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
}
clearCrmRealtimeReconnectTimer();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/crm-updates`;
const socket = new WebSocket(url);
crmRealtimeSocket = socket;
crmRealtimeState.value = "connecting";
socket.onopen = () => {
crmRealtimeState.value = "open";
crmRealtimeReconnectAttempt = 0;
};
socket.onmessage = (event) => {
const raw = typeof event.data === "string" ? event.data : "";
if (!raw) return;
try {
const payload = JSON.parse(raw) as { type?: string };
if (payload.type === "dashboard.changed") {
scheduleCrmRealtimeRefresh();
}
} catch {
// ignore malformed realtime payloads
}
};
socket.onerror = () => {
crmRealtimeState.value = "error";
};
socket.onclose = () => {
const wasActive = crmRealtimeSocket === socket;
if (wasActive) {
crmRealtimeSocket = null;
}
if (!authMe.value) {
crmRealtimeState.value = "idle";
return;
}
crmRealtimeState.value = "error";
scheduleCrmRealtimeReconnect();
};
}
async function sendPilotText(rawText: string) {
const text = safeTrim(rawText);
if (!text || pilotSending.value) return;
const contextPayload = buildContextPayload();
pilotSending.value = true;
pilotInput.value = "";
livePilotUserText.value = text;
livePilotAssistantText.value = "";
pilotLiveLogsExpanded.value = false;
pilotLiveLogs.value = [];
try {
await pilotChat.sendMessage(
{ text },
contextPayload
? {
body: {
contextPayload,
},
}
: undefined,
);
} catch {
pilotInput.value = text;
} finally {
const latestAssistant = [...pilotChat.messages]
.reverse()
.find((message) => message.role === "assistant");
if (latestAssistant) {
const textPart = latestAssistant.parts.find(isTextUIPart);
livePilotAssistantText.value = textPart?.text ?? "";
}
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotSending.value = false;
await Promise.all([refetchChatMessages(), refetchChatConversations(), refetchAllCrmQueries()]);
}
}
async function sendPilotMessage() {
await sendPilotText(pilotInput.value);
}
async function loadWaveSurferModules() {
if (!waveSurferModulesPromise) {
waveSurferModulesPromise = Promise.all([
import("wavesurfer.js"),
import("wavesurfer.js/dist/plugins/record.esm.js"),
]).then(([ws, rec]) => ({
WaveSurfer: ws.default,
RecordPlugin: rec.default,
}));
}
return waveSurferModulesPromise;
}
function destroyCommCallWave(itemId: string) {
const ws = commCallWaveSurfers.get(itemId);
if (!ws) return;
ws.destroy();
commCallWaveSurfers.delete(itemId);
const nextPlayable = { ...commCallPlayableById.value };
delete nextPlayable[itemId];
commCallPlayableById.value = nextPlayable;
const nextPlaying = { ...commCallPlayingById.value };
delete nextPlaying[itemId];
commCallPlayingById.value = nextPlaying;
}
function destroyAllCommCallWaves() {
for (const itemId of commCallWaveSurfers.keys()) {
destroyCommCallWave(itemId);
}
commCallWaveHosts.clear();
}
function setCommCallPlaying(itemId: string, value: boolean) {
commCallPlayingById.value = {
...commCallPlayingById.value,
[itemId]: value,
};
}
function isCommCallPlaying(itemId: string) {
return Boolean(commCallPlayingById.value[itemId]);
}
function isCommCallPlayable(item: CommItem) {
const known = commCallPlayableById.value[item.id];
if (typeof known === "boolean") return known;
return Boolean(getCallAudioUrl(item));
}
function pauseOtherCommCallWaves(currentItemId: string) {
for (const [itemId, ws] of commCallWaveSurfers.entries()) {
if (itemId === currentItemId) continue;
ws.pause?.();
setCommCallPlaying(itemId, false);
}
}
function parseDurationToSeconds(raw?: string) {
if (!raw) return 0;
const text = raw.trim().toLowerCase();
if (!text) return 0;
const ms = text.match(/(\d+)\s*m(?:in)?\s*(\d+)?\s*s?/);
if (ms) {
const m = Number(ms[1] ?? 0);
const s = Number(ms[2] ?? 0);
return m * 60 + s;
}
const colon = text.match(/(\d+):(\d+)/);
if (colon) {
return Number(colon[1] ?? 0) * 60 + Number(colon[2] ?? 0);
}
const sec = text.match(/(\d+)\s*s/);
if (sec) return Number(sec[1] ?? 0);
return 0;
}
function buildCallWavePeaks(item: CommItem, size = 320) {
const stored = Array.isArray(item.waveform)
? item.waveform.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0)
: [];
if (stored.length) {
const sampled = new Float32Array(size);
for (let i = 0; i < size; i += 1) {
const t = size <= 1 ? 0 : i / (size - 1);
const idx = Math.min(stored.length - 1, Math.round(t * (stored.length - 1)));
sampled[i] = Math.max(0.05, Math.min(1, stored[idx] ?? 0.05));
}
return sampled;
}
const source = `${item.text} ${(item.transcript ?? []).join(" ")}`.trim() || item.contact;
let seed = 0;
for (let i = 0; i < source.length; i += 1) {
seed = (seed * 31 + source.charCodeAt(i)) >>> 0;
}
const rand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0xffffffff;
};
const out = new Float32Array(size);
let smooth = 0;
for (let i = 0; i < size; i += 1) {
const t = i / Math.max(1, size - 1);
const burst = Math.max(0, Math.sin(t * Math.PI * (3 + (source.length % 7))));
const noise = (rand() * 2 - 1) * 0.65;
smooth = smooth * 0.7 + noise * 0.3;
out[i] = Math.max(0.05, Math.min(1, 0.12 + Math.abs(smooth) * 0.48 + burst * 0.4));
}
return out;
}
function getCallAudioUrl(item?: CommItem) {
return String(item?.audioUrl ?? "").trim();
}
async function ensureCommCallWave(itemId: string) {
const host = commCallWaveHosts.get(itemId);
if (!host) return;
if (commCallWaveSurfers.has(itemId)) return;
const callItem = visibleThreadItems.value.find((item) => item.id === itemId && item.kind === "call");
if (!callItem) return;
const { WaveSurfer } = await loadWaveSurferModules();
const durationSeconds =
parseDurationToSeconds(callItem.duration) ||
Math.max(8, Math.min(120, Math.round(((callItem.transcript ?? []).join(" ").length || callItem.text.length) / 10)));
const peaks = buildCallWavePeaks(callItem, 360);
const audioUrl = getCallAudioUrl(callItem);
const ws = WaveSurfer.create({
container: host,
height: 30,
waveColor: "rgba(180, 206, 255, 0.88)",
progressColor: "rgba(118, 157, 248, 0.95)",
cursorWidth: 0,
interact: Boolean(audioUrl),
normalize: true,
barWidth: 0,
});
ws.on("play", () => setCommCallPlaying(itemId, true));
ws.on("pause", () => setCommCallPlaying(itemId, false));
ws.on("finish", () => setCommCallPlaying(itemId, false));
let playable = false;
if (audioUrl) {
try {
await ws.load(audioUrl, [peaks], durationSeconds);
playable = true;
} catch {
await ws.load("", [peaks], durationSeconds);
playable = false;
}
} else {
await ws.load("", [peaks], durationSeconds);
}
commCallPlayableById.value = {
...commCallPlayableById.value,
[itemId]: playable,
};
commCallWaveSurfers.set(itemId, ws);
}
async function syncCommCallWaves() {
await nextTick();
const activeCallIds = new Set(
threadStreamItems.value.filter((entry) => entry.kind === "call").map((entry: any) => entry.item.id as string),
);
for (const id of commCallWaveSurfers.keys()) {
if (!activeCallIds.has(id) || !commCallWaveHosts.has(id)) {
destroyCommCallWave(id);
}
}
for (const id of activeCallIds) {
if (commCallWaveHosts.has(id)) {
await ensureCommCallWave(id);
}
}
}
function setCommCallWaveHost(itemId: string, element: Element | null) {
if (!(element instanceof HTMLDivElement)) {
commCallWaveHosts.delete(itemId);
destroyCommCallWave(itemId);
return;
}
commCallWaveHosts.set(itemId, element);
void ensureCommCallWave(itemId);
}
async function ensurePilotWaveSurfer() {
if (pilotWaveSurfer && pilotWaveRecordPlugin) return;
if (!pilotWaveContainer.value) return;
const { WaveSurfer, RecordPlugin } = await loadWaveSurferModules();
pilotWaveSurfer = WaveSurfer.create({
container: pilotWaveContainer.value,
height: 22,
waveColor: "rgba(208, 226, 255, 0.95)",
progressColor: "rgba(141, 177, 255, 0.95)",
cursorWidth: 0,
normalize: true,
interact: false,
});
pilotWaveRecordPlugin = pilotWaveSurfer.registerPlugin(
RecordPlugin.create({
renderRecordedAudio: false,
scrollingWaveform: true,
scrollingWaveformWindow: 10,
mediaRecorderTimeslice: 250,
}),
);
}
async function stopPilotMeter() {
if (pilotWaveMicSession) {
pilotWaveMicSession.onDestroy();
pilotWaveMicSession = null;
}
}
async function startPilotMeter(stream: MediaStream) {
await nextTick();
await ensurePilotWaveSurfer();
await stopPilotMeter();
if (!pilotWaveRecordPlugin) return;
pilotWaveMicSession = pilotWaveRecordPlugin.renderMicStream(stream);
}
function appendPilotTranscript(text: string) {
const next = safeTrim(text);
if (!next) return "";
const merged = pilotInput.value.trim() ? `${pilotInput.value.trim()} ${next}` : next;
pilotInput.value = merged;
return merged;
}
async function transcribeRecordedPilotAudio(blob: Blob) {
pilotMicError.value = null;
pilotTranscribing.value = true;
try {
const text = await transcribeAudioBlob(blob);
if (!text) {
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
return null;
}
return text;
} catch (error: any) {
pilotMicError.value = String(error?.data?.message ?? error?.message ?? "Ошибка распознавания аудио");
return null;
} finally {
pilotTranscribing.value = false;
}
}
async function startPilotRecording() {
if (pilotRecording.value || pilotTranscribing.value) return;
pilotMicError.value = null;
if (!pilotMicSupported.value) {
pilotMicError.value = "Запись не поддерживается в этом браузере.";
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
pilotRecorderStream = stream;
pilotRecorderMimeType = recorder.mimeType || "audio/webm";
pilotMediaRecorder = recorder;
pilotRecordingFinishMode = "fill";
pilotRecordingChunks = [];
pilotRecording.value = true;
void startPilotMeter(stream);
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) pilotRecordingChunks.push(event.data);
};
recorder.onstop = async () => {
pilotRecording.value = false;
await stopPilotMeter();
const mode = pilotRecordingFinishMode;
pilotRecordingFinishMode = "fill";
const audioBlob = new Blob(pilotRecordingChunks, { type: pilotRecorderMimeType });
pilotRecordingChunks = [];
pilotMediaRecorder = null;
if (pilotRecorderStream) {
pilotRecorderStream.getTracks().forEach((track) => track.stop());
pilotRecorderStream = null;
}
if (audioBlob.size > 0) {
const transcript = await transcribeRecordedPilotAudio(audioBlob);
if (!transcript) return;
const mergedText = appendPilotTranscript(transcript);
if (mode === "send" && !pilotSending.value && mergedText.trim()) {
await sendPilotText(mergedText);
return;
}
}
};
recorder.start();
} catch {
pilotMicError.value = "Нет доступа к микрофону.";
pilotRecording.value = false;
}
}
function stopPilotRecording(mode: "fill" | "send" = "fill") {
if (!pilotMediaRecorder || pilotMediaRecorder.state === "inactive") return;
pilotRecordingFinishMode = mode;
pilotRecording.value = false;
pilotMediaRecorder.stop();
}
function togglePilotRecording() {
if (pilotRecording.value) {
stopPilotRecording("fill");
} else {
startPilotRecording();
}
}
function handlePilotSendAction() {
if (pilotRecording.value) {
stopPilotRecording("send");
return;
}
void sendPilotMessage();
}
function handlePilotComposerEnter(event: KeyboardEvent) {
if (event.shiftKey) return;
event.preventDefault();
handlePilotSendAction();
}
function startPilotBackgroundPolling() {
if (pilotBackgroundPoll) return;
pilotBackgroundPoll = setInterval(() => {
if (!authMe.value) return;
loadPilotMessages().catch(() => {});
}, 2000);
}
function stopPilotBackgroundPolling() {
if (!pilotBackgroundPoll) return;
clearInterval(pilotBackgroundPoll);
pilotBackgroundPoll = null;
}
watchEffect(() => {
if (!pilotSending.value) return;
const latestAssistant = [...pilotChat.messages]
.reverse()
.find((message) => message.role === "assistant");
if (!latestAssistant) return;
const textPart = latestAssistant.parts.find(isTextUIPart);
livePilotAssistantText.value = textPart?.text ?? "";
});
const changeActionBusy = ref(false);
const activeChangeSetId = ref("");
const activeChangeStep = ref(0);
const focusedCalendarEventId = ref("");
const uiPathSyncLocked = ref(false);
let popstateHandler: (() => void) | null = null;
const pilotHeaderPhrases = [
"Every step moves you forward",
"Focus first, results follow",
"Break down hard things into simple moves",
"Finish what matters today",
"Less noise, more action",
"Systems beat chaos",
"Important before urgent",
"The best moment to start is now",
];
const pilotHeaderText = ref("Every step moves you forward");
const latestChangeMessage = computed(() => {
return (
[...pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
);
});
const activeChangeMessage = computed(() => {
const targetId = activeChangeSetId.value.trim();
if (!targetId) return latestChangeMessage.value;
return (
[...pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
);
});
const activeChangeItems = computed(() => activeChangeMessage.value?.changeItems ?? []);
const activeChangeIndex = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return 0;
return Math.max(0, Math.min(activeChangeStep.value, items.length - 1));
});
const activeChangeItem = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return null;
return items[activeChangeIndex.value] ?? null;
});
const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0));
const activeChangeStepNumber = computed(() => activeChangeIndex.value + 1);
const rollbackableCount = computed(() => activeChangeItems.value.filter((item) => !item.rolledBack).length);
const activeReviewCalendarEventId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "calendar_event" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return "";
return item.entityId;
});
const activeReviewDealId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "deal" || !item.entityId) return "";
return item.entityId;
});
const activeReviewMessageId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "message" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactDiff = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return null;
return {
contactId: item.entityId,
before: normalizeChangeText(item.before),
after: normalizeChangeText(item.after),
};
});
function normalizeChangeText(raw: string | null | undefined) {
const text = String(raw ?? "").trim();
if (!text) return "";
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
if (typeof parsed === "object" && parsed) {
const candidate = [parsed.description, parsed.summary, parsed.note, parsed.text]
.find((value) => typeof value === "string");
if (typeof candidate === "string") return candidate.trim();
}
} catch {
// No-op: keep original text when it is not JSON payload.
}
return text;
}
function describeChangeEntity(entity: string) {
if (entity === "contact_note") return "Contact summary";
if (entity === "calendar_event") return "Calendar event";
if (entity === "message") return "Message";
if (entity === "deal") return "Deal";
if (entity === "workspace_document") return "Workspace document";
return entity || "Change";
}
function describeChangeAction(action: string) {
if (action === "created") return "created";
if (action === "updated") return "updated";
if (action === "deleted") return "archived";
return action || "changed";
}
function calendarCursorToken(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
function calendarRouteToken(view: CalendarView) {
if (view === "day" || view === "week") {
return selectedDateKey.value;
}
if (view === "year") {
return String(calendarCursor.value.getFullYear());
}
return calendarCursorToken(calendarCursor.value);
}
function parseCalendarCursorToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) return null;
return new Date(year, month - 1, 1);
}
function parseCalendarDateToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const parsed = new Date(year, month - 1, day);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function parseCalendarYearToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})$/);
if (!m) return null;
const year = Number(m[1]);
if (!Number.isFinite(year)) return null;
return year;
}
function normalizedConversationId() {
return safeTrim(selectedChatId.value || authMe.value?.conversation.id || "pilot");
}
function currentUiPath() {
if (selectedTab.value === "documents") {
const docId = selectedDocumentId.value.trim();
if (docId) {
return withReviewQuery(`/documents/${encodeURIComponent(docId)}`);
}
return withReviewQuery("/documents");
}
if (peopleLeftMode.value === "calendar") {
if (focusedCalendarEventId.value.trim()) {
return withReviewQuery(`/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`);
}
return withReviewQuery(`/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarRouteToken(calendarView.value))}`);
}
if (peopleListMode.value === "deals" && selectedDealId.value.trim()) {
return withReviewQuery(`/deal/${encodeURIComponent(selectedDealId.value.trim())}`);
}
if (selectedContactId.value.trim()) {
return withReviewQuery(`/contact/${encodeURIComponent(selectedContactId.value.trim())}`);
}
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
}
function withReviewQuery(path: string) {
const reviewSet = activeChangeSetId.value.trim();
if (!reviewSet) return path;
const params = new URLSearchParams();
params.set("reviewSet", reviewSet);
params.set("reviewStep", String(Math.max(1, activeChangeStep.value + 1)));
return `${path}?${params.toString()}`;
}
function syncPathFromUi(push = false) {
if (process.server) return;
const nextPath = currentUiPath();
const currentPath = `${window.location.pathname}${window.location.search}`;
if (nextPath === currentPath) return;
if (push) {
window.history.pushState({}, "", nextPath);
} else {
window.history.replaceState({}, "", nextPath);
}
}
function setPeopleLeftMode(mode: PeopleLeftMode, push = false) {
selectedTab.value = "communications";
peopleLeftMode.value = mode;
focusedCalendarEventId.value = "";
syncPathFromUi(push);
}
function openChangeReview(changeSetId: string, step = 0, push = true) {
const targetId = String(changeSetId ?? "").trim();
if (!targetId) return;
activeChangeSetId.value = targetId;
const items = activeChangeMessage.value?.changeItems ?? [];
activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0;
applyReviewStepToUi(push);
}
function applyPathToUi(pathname: string, search = "") {
const path = String(pathname || "/").trim() || "/";
const params = new URLSearchParams(String(search || ""));
const reviewSet = (params.get("reviewSet") ?? "").trim();
const reviewStep = Number(params.get("reviewStep") ?? "1");
if (reviewSet) {
activeChangeSetId.value = reviewSet;
activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
} else {
activeChangeSetId.value = "";
activeChangeStep.value = 0;
}
const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
if (calendarEventMatch) {
const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
const event = sortedEvents.value.find((x) => x.id === rawEventId);
if (event) {
pickDate(event.start.slice(0, 10));
}
focusedCalendarEventId.value = rawEventId;
return;
}
const calendarMatch = path.match(/^\/calendar\/([^/]+)\/([^/]+)\/?$/i);
if (calendarMatch) {
const rawView = decodeURIComponent(calendarMatch[1] ?? "").trim();
const rawCursor = decodeURIComponent(calendarMatch[2] ?? "").trim();
const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView)
? (rawView as CalendarView)
: "month";
const cursorByMonth = parseCalendarCursorToken(rawCursor);
const cursorByDate = parseCalendarDateToken(rawCursor);
const cursorByYear = parseCalendarYearToken(rawCursor);
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
focusedCalendarEventId.value = "";
calendarView.value = view;
if (view === "day" || view === "week") {
const parsed = cursorByDate;
if (parsed) {
selectedDateKey.value = dayKey(parsed);
calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
}
} else if (view === "year") {
if (cursorByYear) {
calendarCursor.value = new Date(cursorByYear, 0, 1);
selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
}
} else if (cursorByMonth) {
calendarCursor.value = cursorByMonth;
selectedDateKey.value = dayKey(cursorByMonth);
}
return;
}
const documentsMatch = path.match(/^\/documents(?:\/([^/]+))?\/?$/i);
if (documentsMatch) {
const rawDocumentId = decodeURIComponent(documentsMatch[1] ?? "").trim();
selectedTab.value = "documents";
focusedCalendarEventId.value = "";
if (rawDocumentId) selectedDocumentId.value = rawDocumentId;
return;
}
const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
if (contactMatch) {
const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
if (rawContactId) {
selectedContactId.value = rawContactId;
const linkedThread = commThreads.value.find((thread) => thread.id === rawContactId);
if (linkedThread) selectedCommThreadId.value = linkedThread.id;
}
focusedCalendarEventId.value = "";
return;
}
const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
if (dealMatch) {
const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
peopleListMode.value = "deals";
if (rawDealId) {
selectedDealId.value = rawDealId;
const linkedDeal = deals.value.find((deal) => deal.id === rawDealId);
const linkedContact = linkedDeal
? contacts.value.find((contact) => contact.name === linkedDeal.contact)
: null;
if (linkedContact) {
selectedContactId.value = linkedContact.id;
selectedCommThreadId.value = linkedContact.id;
}
}
focusedCalendarEventId.value = "";
return;
}
const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
if (chatMatch) {
const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
focusedCalendarEventId.value = "";
if (rawChatId) selectedChatId.value = rawChatId;
return;
}
const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i);
if (changesMatch) {
const rawId = decodeURIComponent(changesMatch[1] ?? "").trim();
const rawStep = Number(changesMatch[2] ?? "1");
if (rawId) {
activeChangeSetId.value = rawId;
activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
}
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
focusedCalendarEventId.value = "";
return;
}
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
focusedCalendarEventId.value = "";
}
async function confirmLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doConfirmLatestChangeSet();
} finally {
changeActionBusy.value = false;
}
}
async function rollbackLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doRollbackLatestChangeSet();
activeChangeSetId.value = "";
activeChangeStep.value = 0;
setPeopleLeftMode("contacts");
} finally {
changeActionBusy.value = false;
}
}
async function rollbackSelectedChangeItems() {
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
const itemIds = activeChangeItems.value.filter((item) => !item.rolledBack).map((item) => item.id);
if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds });
} finally {
changeActionBusy.value = false;
}
}
async function rollbackChangeItemById(itemId: string) {
const item = activeChangeItems.value.find((entry) => entry.id === itemId);
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
if (!item || item.rolledBack || !targetChangeSetId || changeActionBusy.value) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds: [itemId] });
} finally {
changeActionBusy.value = false;
}
}
function goToChangeStep(step: number) {
const items = activeChangeItems.value;
if (!items.length) return;
activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
applyReviewStepToUi(true);
}
function goToPreviousChangeStep() {
goToChangeStep(activeChangeIndex.value - 1);
}
function goToNextChangeStep() {
goToChangeStep(activeChangeIndex.value + 1);
}
function openChangeItemTarget(item: PilotChangeItem) {
if (!item) return;
const idx = activeChangeItems.value.findIndex((candidate) => candidate.id === item.id);
if (idx >= 0) {
goToChangeStep(idx);
}
}
function isReviewHighlightedEvent(eventId: string) {
return Boolean(reviewActive.value && activeReviewCalendarEventId.value && activeReviewCalendarEventId.value === eventId);
}
function isReviewHighlightedContact(contactId: string) {
return Boolean(reviewActive.value && activeReviewContactId.value && activeReviewContactId.value === contactId);
}
function isReviewHighlightedDeal(dealId: string) {
return Boolean(reviewActive.value && activeReviewDealId.value && activeReviewDealId.value === dealId);
}
function isReviewHighlightedMessage(messageId: string) {
return Boolean(reviewActive.value && activeReviewMessageId.value && activeReviewMessageId.value === messageId);
}
function applyReviewStepToUi(push = false) {
const item = activeChangeItem.value;
if (!item) {
syncPathFromUi(push);
return;
}
selectedTab.value = "communications";
if (item.entity === "calendar_event" && item.entityId) {
peopleLeftMode.value = "calendar";
calendarView.value = "month";
const event = sortedEvents.value.find((x) => x.id === item.entityId);
if (event) {
pickDate(event.start.slice(0, 10));
}
focusedCalendarEventId.value = item.entityId;
syncPathFromUi(push);
return;
}
if (item.entity === "contact_note" && item.entityId) {
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
selectedContactId.value = item.entityId;
const thread = commThreads.value.find((entry) => entry.id === item.entityId);
if (thread) selectedCommThreadId.value = thread.id;
focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "deal" && item.entityId) {
peopleLeftMode.value = "contacts";
peopleListMode.value = "deals";
selectedDealId.value = item.entityId;
const deal = deals.value.find((entry) => entry.id === item.entityId);
if (deal) {
const contact = contacts.value.find((entry) => entry.name === deal.contact);
if (contact) {
selectedContactId.value = contact.id;
selectedCommThreadId.value = contact.id;
}
}
focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "message" && item.entityId) {
peopleLeftMode.value = "contacts";
peopleListMode.value = "contacts";
const message = commItems.value.find((entry) => entry.id === item.entityId);
if (message?.contact) {
openCommunicationThread(message.contact);
}
focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "workspace_document" && item.entityId) {
selectedTab.value = "documents";
selectedDocumentId.value = item.entityId;
focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
peopleLeftMode.value = "contacts";
focusedCalendarEventId.value = "";
syncPathFromUi(push);
}
function finishReview(push = true) {
activeChangeSetId.value = "";
activeChangeStep.value = 0;
syncPathFromUi(push);
}
watch(
() => activeChangeMessage.value?.changeSetId,
() => {
if (!activeChangeSetId.value.trim()) return;
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
applyReviewStepToUi(false);
},
);
if (process.server) {
await bootstrapSession();
}
onMounted(() => {
pilotHeaderText.value = pilotHeaderPhrases[Math.floor(Math.random() * pilotHeaderPhrases.length)] ?? "Every step moves you forward";
pilotMicSupported.value = isVoiceCaptureSupported();
lifecycleClock = setInterval(() => {
lifecycleNowMs.value = Date.now();
}, 15000);
uiPathSyncLocked.value = true;
try {
const params = new URLSearchParams(window.location.search);
const tgLinkToken = String(params.get("tg_link_token") ?? "").trim();
if (tgLinkToken) {
void completeTelegramBusinessConnectFromToken(tgLinkToken);
params.delete("tg_link_token");
const nextSearch = params.toString();
window.history.replaceState({}, "", `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`);
}
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
syncPathFromUi(false);
popstateHandler = () => {
uiPathSyncLocked.value = true;
try {
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
};
window.addEventListener("popstate", popstateHandler);
window.addEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
window.addEventListener("keydown", onWindowKeyDownForCommPinMenu);
if (!authResolved.value) {
void bootstrapSession().finally(() => {
if (authMe.value) {
startPilotBackgroundPolling();
startCrmRealtime();
}
});
return;
}
if (authMe.value) {
startPilotBackgroundPolling();
startCrmRealtime();
}
});
onBeforeUnmount(() => {
stopCrmRealtime();
if (pilotRecording.value) {
stopPilotRecording("fill");
}
stopEventArchiveRecording();
destroyAllCommCallWaves();
void stopPilotMeter();
if (pilotWaveSurfer) {
pilotWaveSurfer.destroy();
pilotWaveSurfer = null;
pilotWaveRecordPlugin = null;
}
if (pilotRecorderStream) {
pilotRecorderStream.getTracks().forEach((track) => track.stop());
pilotRecorderStream = null;
}
stopPilotBackgroundPolling();
if (popstateHandler) {
window.removeEventListener("popstate", popstateHandler);
popstateHandler = null;
}
window.removeEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
window.removeEventListener("keydown", onWindowKeyDownForCommPinMenu);
if (lifecycleClock) {
clearInterval(lifecycleClock);
lifecycleClock = null;
}
if (calendarViewportResizeObserver) {
calendarViewportResizeObserver.disconnect();
calendarViewportResizeObserver = null;
}
clearCalendarZoomPrime();
calendarKillTweens();
});
const calendarView = ref<CalendarView>("year");
const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const selectedDateKey = ref(dayKey(new Date()));
const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
const focusedCalendarEvent = computed(() => {
const id = (focusedCalendarEventId.value ?? "").trim();
if (!id) return null;
return sortedEvents.value.find((event) => event.id === id) ?? null;
});
const eventsByDate = computed(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of sortedEvents.value) {
const key = event.start.slice(0, 10);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(event);
}
return map;
});
function getEventsByDate(key: string) {
return eventsByDate.value.get(key) ?? [];
}
const monthLabel = computed(() =>
new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value),
);
const calendarViewOptions: { value: CalendarView; label: string }[] = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
{ value: "agenda", label: "Agenda" },
];
type CalendarHierarchyView = "year" | "month" | "week" | "day";
type CalendarRect = { left: number; top: number; width: number; height: number };
const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = ref<HTMLElement | null>(null);
const calendarFlyRectRef = ref<HTMLDivElement | null>(null);
const calendarFlyVisible = ref(false);
const calendarViewportHeight = ref(0);
let calendarActiveTweens: gsap.core.Tween[] = [];
const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref("");
let calendarViewportResizeObserver: ResizeObserver | null = null;
function setCalendarContentWrapRef(element: HTMLElement | null) {
calendarContentWrapRef.value = element;
}
function setCalendarFlyRectRef(element: HTMLDivElement | null) {
calendarFlyRectRef.value = element;
}
function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
return new Promise((resolve) => {
const t = gsap.to(target, {
...vars,
onComplete: () => {
calendarActiveTweens = calendarActiveTweens.filter((tw) => tw !== t);
resolve();
},
});
calendarActiveTweens.push(t);
});
}
function calendarKillTweens() {
for (const t of calendarActiveTweens) t.kill();
calendarActiveTweens = [];
}
function setCalendarContentScrollRef(element: HTMLElement | null) {
if (calendarViewportResizeObserver) {
calendarViewportResizeObserver.disconnect();
calendarViewportResizeObserver = null;
}
calendarContentScrollRef.value = element;
if (element && typeof ResizeObserver !== "undefined") {
calendarViewportResizeObserver = new ResizeObserver(() => {
calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
});
calendarViewportResizeObserver.observe(element);
calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
}
}
function setCalendarSceneRef(element: HTMLElement | null) {
calendarSceneRef.value = element;
}
function setCalendarHoveredMonthIndex(value: number | null) {
calendarHoveredMonthIndex.value = value;
}
function setCalendarHoveredWeekStartKey(value: string) {
calendarHoveredWeekStartKey.value = value;
}
function setCalendarHoveredDayKey(value: string) {
calendarHoveredDayKey.value = value;
}
function onCalendarSceneMouseLeave() {
calendarHoveredMonthIndex.value = null;
calendarHoveredWeekStartKey.value = "";
calendarHoveredDayKey.value = "";
clearCalendarZoomPrime();
}
const calendarZoomBusy = ref(false);
const calendarZoomPrimeToken = ref("");
const calendarZoomPrimeScale = ref(1);
const calendarZoomPrimeTicks = ref(0);
let calendarWheelLockUntil = 0;
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
let calendarZoomPrimeLastAt = 0;
const CALENDAR_ZOOM_DURATION_MS = 650;
const CALENDAR_FLY_DURATION = 0.65;
const CALENDAR_FADE_DURATION = 0.18;
const CALENDAR_EASE = "power3.inOut";
const CALENDAR_ZOOM_PRIME_STEPS = 2;
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
calendarView.value === "agenda" ? "month" : calendarView.value,
);
const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value)));
function clearCalendarZoomPrime() {
if (calendarZoomPrimeTimer) {
clearTimeout(calendarZoomPrimeTimer);
calendarZoomPrimeTimer = null;
}
calendarZoomPrimeToken.value = "";
calendarZoomPrimeScale.value = 1;
calendarZoomPrimeTicks.value = 0;
calendarZoomPrimeLastAt = 0;
}
function calendarPrimeMonthToken(monthIndex: number) {
return `year-month-${monthIndex}`;
}
function calendarPrimeWeekToken(startKey: string) {
return `month-week-${startKey}`;
}
function calendarPrimeDayToken(key: string) {
return `week-day-${key}`;
}
function calendarPrimeStyle(token: string) {
if (calendarZoomPrimeToken.value !== token) return undefined;
return {
transform: `scale(${calendarZoomPrimeScale.value})`,
};
}
function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
if (!event || event.deltaY >= 0) return false;
const now = Date.now();
if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
calendarZoomPrimeTicks.value = 0;
}
calendarZoomPrimeToken.value = token;
calendarZoomPrimeTicks.value += 1;
calendarZoomPrimeLastAt = now;
if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
calendarZoomPrimeTimer = setTimeout(() => {
clearCalendarZoomPrime();
}, CALENDAR_ZOOM_PRIME_RESET_MS);
return true;
}
clearCalendarZoomPrime();
return false;
}
function queryCalendarElement(selector: string) {
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
}
function getCalendarViewportRect(): CalendarRect | null {
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
return {
left: 0,
top: 0,
width: Math.max(24, wrapRect.width),
height: Math.max(24, wrapRect.height),
};
}
function getCalendarCameraViewportRect() {
const viewport = calendarContentScrollRef.value?.getBoundingClientRect();
if (!viewport) return null;
return {
width: Math.max(24, viewport.width),
height: Math.max(24, viewport.height),
};
}
function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
if (!element) return null;
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
if (!wrapRect) return null;
const rect = element.getBoundingClientRect();
const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width));
const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height));
const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width));
const bottom = Math.max(0, Math.min(rect.bottom - wrapRect.top, wrapRect.height));
const visibleWidth = right - left;
const visibleHeight = bottom - top;
if (visibleWidth < 2 || visibleHeight < 2) return null;
const width = Math.min(Math.max(24, visibleWidth), wrapRect.width - left);
const height = Math.min(Math.max(24, visibleHeight), wrapRect.height - top);
return { left, top, width, height };
}
function getElementRectInScene(element: HTMLElement | null): CalendarRect | null {
if (!element) return null;
const sceneRect = calendarSceneRef.value?.getBoundingClientRect();
if (!sceneRect) return null;
const rect = element.getBoundingClientRect();
const left = rect.left - sceneRect.left;
const top = rect.top - sceneRect.top;
const width = Math.max(24, rect.width);
const height = Math.max(24, rect.height);
return { left, top, width, height };
}
function fallbackZoomOriginRectInScene(): CalendarRect | null {
const viewport = getCalendarCameraViewportRect();
const scroll = calendarContentScrollRef.value;
if (!viewport || !scroll) return null;
const width = Math.max(96, Math.round(viewport.width * 0.28));
const height = Math.max(64, Math.round(viewport.height * 0.24));
return {
left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
width,
height,
};
}
function weekRowStartForDate(key: string) {
const date = new Date(`${key}T00:00:00`);
date.setDate(date.getDate() - date.getDay());
return dayKey(date);
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
async function animateCalendarFlipTransition(
_sourceElement: HTMLElement | null,
apply: () => void,
resolveTarget: () => HTMLElement | null,
) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
calendarKillTweens();
const flyEl = calendarFlyRectRef.value;
const wrapEl = calendarContentWrapRef.value;
const sceneEl = calendarSceneRef.value;
if (!flyEl || !wrapEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
const wrapRect = wrapEl.getBoundingClientRect();
// 1. Fade out current content
if (sceneEl) {
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
}
// 2. Position fly rect at full viewport
const pad = 4;
gsap.set(flyEl, {
left: pad,
top: pad,
width: wrapRect.width - pad * 2,
height: wrapRect.height - pad * 2,
opacity: 1,
borderRadius: 14,
});
calendarFlyVisible.value = true;
// 3. Switch to parent view
apply();
await nextTick();
// 4. Find target element in new view
const targetElement = resolveTarget();
const targetRect = targetElement?.getBoundingClientRect() ?? null;
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
const tgtLeft = targetRect.left - wrapRect.left;
const tgtTop = targetRect.top - wrapRect.top;
// 5. Animate fly rect → target element
await calendarTweenTo(flyEl, {
left: tgtLeft,
top: tgtTop,
width: targetRect.width,
height: targetRect.height,
borderRadius: 12,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
}
// 6. Hide fly rect, fade in content
calendarFlyVisible.value = false;
if (sceneEl) {
gsap.set(sceneEl, { opacity: 0 });
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
} finally {
calendarFlyVisible.value = false;
calendarZoomBusy.value = false;
}
}
async function animateCalendarZoomIntoSource(
sourceElement: HTMLElement | null,
apply: () => void,
) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
calendarKillTweens();
const flyEl = calendarFlyRectRef.value;
const wrapEl = calendarContentWrapRef.value;
const scrollEl = calendarContentScrollRef.value;
const sceneEl = calendarSceneRef.value;
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
apply();
calendarZoomBusy.value = false;
return;
}
try {
const wrapRect = wrapEl.getBoundingClientRect();
const sourceRect = sourceElement.getBoundingClientRect();
if (sourceRect.width < 2 || sourceRect.height < 2) {
apply();
return;
}
// 1. Fade out siblings
const siblings = Array.from(
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
// 2. Position fly rect at source element
const srcLeft = sourceRect.left - wrapRect.left;
const srcTop = sourceRect.top - wrapRect.top;
gsap.set(flyEl, {
left: srcLeft,
top: srcTop,
width: sourceRect.width,
height: sourceRect.height,
opacity: 1,
borderRadius: 12,
});
calendarFlyVisible.value = true;
// 3. Animate fly rect → full viewport
const pad = 4;
await calendarTweenTo(flyEl, {
left: pad,
top: pad,
width: wrapRect.width - pad * 2,
height: wrapRect.height - pad * 2,
borderRadius: 14,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
// 4. Switch content
apply();
await nextTick();
// 5. Hide fly rect, fade in new content
calendarFlyVisible.value = false;
if (sceneEl) {
gsap.set(sceneEl, { opacity: 0 });
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
// 6. Restore sibling opacity
for (const el of siblings) {
el.style.opacity = "";
}
} finally {
calendarFlyVisible.value = false;
calendarZoomBusy.value = false;
}
}
function resolveMonthAnchor(event?: WheelEvent) {
const target = event?.target as HTMLElement | null;
const monthAttr = target?.closest<HTMLElement>("[data-calendar-month-index]")?.dataset.calendarMonthIndex;
if (monthAttr) {
const parsed = Number(monthAttr);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed;
}
if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value;
return calendarCursor.value.getMonth();
}
function fallbackMonthGridAnchorKey() {
if (monthCells.value.some((cell) => cell.key === selectedDateKey.value)) return selectedDateKey.value;
const middle = dayKey(new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 15));
if (monthCells.value.some((cell) => cell.key === middle)) return middle;
return monthCells.value.find((cell) => cell.inMonth)?.key ?? monthCells.value[0]?.key ?? selectedDateKey.value;
}
function resolveWeekAnchor(event?: WheelEvent) {
const target = event?.target as HTMLElement | null;
const weekKey = target?.closest<HTMLElement>("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
if (weekKey) return weekKey;
if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return fallbackMonthGridAnchorKey();
}
function resolveDayAnchor(event?: WheelEvent) {
const target = event?.target as HTMLElement | null;
const dayKeyAttr = target?.closest<HTMLElement>("[data-calendar-day-key]")?.dataset.calendarDayKey;
if (dayKeyAttr) return dayKeyAttr;
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
return weekDays.value[0]?.key ?? selectedDateKey.value;
}
async function zoomInCalendar(event?: Event) {
const wheelEvent = event instanceof WheelEvent ? event : undefined;
if (calendarView.value === "year") {
const monthIndex = resolveMonthAnchor(wheelEvent);
const sourceElement =
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
queryCalendarElement("[data-calendar-month-index]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
await animateCalendarZoomIntoSource(sourceElement, () => {
openYearMonth(monthIndex);
});
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const anchorDayKey = resolveWeekAnchor(wheelEvent);
const rowStartKey = weekRowStartForDate(anchorDayKey);
const sourceElement =
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${anchorDayKey}"]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIntoSource(sourceElement, () => {
openWeekView(anchorDayKey);
});
return;
}
if (calendarView.value === "week") {
const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement =
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${dayAnchor}"]`) ??
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`);
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIntoSource(sourceElement, () => {
openDayView(dayAnchor);
});
}
}
async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIntoSource(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
openYearMonth(monthIndex);
});
}
async function zoomOutCalendar() {
focusedCalendarEventId.value = "";
clearCalendarZoomPrime();
if (calendarView.value === "day") {
const targetDayKey = selectedDateKey.value;
await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
() => {
calendarView.value = "week";
},
() =>
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${targetDayKey}"]`) ??
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`),
);
return;
}
if (calendarView.value === "week") {
const targetRowKey = weekRowStartForDate(selectedDateKey.value);
await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
() => {
calendarView.value = "month";
},
() =>
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${targetRowKey}"]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${selectedDateKey.value}"]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`),
);
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const targetMonthIndex = calendarCursor.value.getMonth();
await animateCalendarFlipTransition(
queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
() => {
calendarView.value = "year";
},
() => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
);
}
}
function onCalendarHierarchyWheel(event: WheelEvent) {
const now = Date.now();
if (calendarZoomBusy.value) return;
if (now < calendarWheelLockUntil) return;
if (Math.abs(event.deltaY) < 5) return;
calendarWheelLockUntil = now + 140;
if (event.deltaY < 0) {
void zoomInCalendar(event);
return;
}
void zoomOutCalendar();
}
async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
const targetIndex = calendarZoomOrder.indexOf(targetView);
if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
while (currentIndex !== targetIndex) {
if (targetIndex > currentIndex) {
await zoomInCalendar();
} else {
await zoomOutCalendar();
}
currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
}
}
function onCalendarZoomSliderInput(event: Event) {
const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
if (!Number.isFinite(value)) return;
const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
const targetView = calendarZoomOrder[targetIndex];
if (!targetView) return;
void setCalendarZoomLevel(targetView);
}
const monthCells = computed(() => {
const year = calendarCursor.value.getFullYear();
const month = calendarCursor.value.getMonth();
const first = new Date(year, month, 1);
const start = new Date(year, month, 1 - first.getDay());
return Array.from({ length: 42 }, (_, index) => {
const d = new Date(start);
d.setDate(start.getDate() + index);
const key = dayKey(d);
return {
key,
day: d.getDate(),
inMonth: d.getMonth() === month,
events: getEventsByDate(key),
};
});
});
const monthRows = computed(() => {
const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
for (let index = 0; index < monthCells.value.length; index += 7) {
const cells = monthCells.value.slice(index, index + 7);
if (!cells.length) continue;
rows.push({
key: `${cells[0]?.key ?? index}-week-row`,
startKey: cells[0]?.key ?? selectedDateKey.value,
cells,
});
}
return rows;
});
function monthCellHasFocusedEvent(events: CalendarEvent[]) {
const id = focusedCalendarEventId.value.trim();
if (!id) return false;
return events.some((event) => event.id === id);
}
function monthCellEvents(events: CalendarEvent[]) {
const id = focusedCalendarEventId.value.trim();
if (!id) return events.slice(0, 2);
const focused = events.find((event) => event.id === id);
if (!focused) return events.slice(0, 2);
const rest = events.filter((event) => event.id !== id).slice(0, 1);
return [focused, ...rest];
}
const weekDays = computed(() => {
const base = new Date(`${selectedDateKey.value}T00:00:00`);
const mondayOffset = (base.getDay() + 6) % 7;
const monday = new Date(base);
monday.setDate(base.getDate() - mondayOffset);
return Array.from({ length: 7 }, (_, index) => {
const d = new Date(monday);
d.setDate(monday.getDate() + index);
const key = dayKey(d);
return {
key,
label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
day: d.getDate(),
events: getEventsByDate(key),
};
});
});
const calendarPeriodLabel = computed(() => {
if (calendarView.value === "month") {
return monthLabel.value;
}
if (calendarView.value === "year") {
return String(calendarCursor.value.getFullYear());
}
if (calendarView.value === "week") {
const first = weekDays.value[0];
const last = weekDays.value[weekDays.value.length - 1];
if (!first || !last) return "";
return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
}
if (calendarView.value === "day") {
return formatDay(`${selectedDateKey.value}T00:00:00`);
}
return `Agenda · ${monthLabel.value}`;
});
const yearMonths = computed(() => {
const year = calendarCursor.value.getFullYear();
return Array.from({ length: 12 }, (_, monthIndex) => {
const monthStart = new Date(year, monthIndex, 1);
const monthEnd = new Date(year, monthIndex + 1, 1);
const items = sortedEvents.value.filter((event) => {
const d = new Date(event.start);
return d >= monthStart && d < monthEnd;
});
return {
monthIndex,
label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
count: items.length,
first: items[0],
};
});
});
const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
function shiftCalendar(step: number) {
focusedCalendarEventId.value = "";
if (calendarView.value === "year") {
const next = new Date(calendarCursor.value);
next.setFullYear(next.getFullYear() + step);
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
const selected = new Date(`${selectedDateKey.value}T00:00:00`);
selected.setFullYear(selected.getFullYear() + step);
selectedDateKey.value = dayKey(selected);
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const next = new Date(calendarCursor.value);
next.setMonth(next.getMonth() + step);
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
return;
}
const current = new Date(`${selectedDateKey.value}T00:00:00`);
const days = calendarView.value === "week" ? 7 : 1;
current.setDate(current.getDate() + days * step);
selectedDateKey.value = dayKey(current);
calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
}
function setToday() {
focusedCalendarEventId.value = "";
const now = new Date();
selectedDateKey.value = dayKey(now);
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
}
function pickDate(key: string) {
focusedCalendarEventId.value = "";
selectedDateKey.value = key;
const d = new Date(`${key}T00:00:00`);
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
}
function openDayView(key: string) {
pickDate(key);
calendarView.value = "day";
}
function openWeekView(key: string) {
pickDate(key);
calendarView.value = "week";
}
function openYearMonth(monthIndex: number) {
focusedCalendarEventId.value = "";
const year = calendarCursor.value.getFullYear();
calendarCursor.value = new Date(year, monthIndex, 1);
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
calendarView.value = "month";
}
const contactSearch = ref("");
const selectedChannel = ref("All");
const sortMode = ref<SortMode>("name");
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
function resetContactFilters() {
contactSearch.value = "";
selectedChannel.value = "All";
sortMode.value = "name";
}
const filteredContacts = computed(() => {
const query = contactSearch.value.trim().toLowerCase();
const data = contacts.value.filter((contact) => {
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
if (query) {
const haystack = [contact.name, contact.description, contact.channels.join(" ")]
.join(" ")
.toLowerCase();
if (!haystack.includes(query)) return false;
}
return true;
});
return data.sort((a, b) => {
if (sortMode.value === "lastContact") {
return b.lastContactAt.localeCompare(a.lastContactAt);
}
return a.name.localeCompare(b.name);
});
});
const groupedContacts = computed(() => {
if (sortMode.value === "lastContact") {
return [["Recent", filteredContacts.value]] as [string, Contact[]][];
}
const map = new Map<string, Contact[]>();
for (const contact of filteredContacts.value) {
const key = (contact.name[0] ?? "#").toUpperCase();
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(contact);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
});
const selectedContactId = ref(contacts.value[0]?.id ?? "");
watchEffect(() => {
if (!filteredContacts.value.length) {
selectedContactId.value = "";
return;
}
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
const first = filteredContacts.value[0];
if (first) selectedContactId.value = first.id;
}
});
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
const selectedContactEvents = computed(() => {
if (!selectedContact.value) return [];
const nowIso = new Date().toISOString();
const events = sortedEvents.value.filter((event) => event.contact === selectedContact.value?.name);
const upcoming = events.filter((event) => event.end >= nowIso);
const past = events.filter((event) => event.end < nowIso).reverse();
return [...upcoming, ...past].slice(0, 8);
});
const selectedContactRecentMessages = computed(() => {
if (!selectedContact.value) return [];
return commItems.value
.filter((item) => item.contact === selectedContact.value?.name && item.kind === "message")
.sort((a, b) => b.at.localeCompare(a.at))
.slice(0, 8);
});
const documentSearch = ref("");
const documentSortMode = ref<DocumentSortMode>("updatedAt");
const documentDeletingId = ref("");
const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
{ value: "updatedAt", label: "Updated" },
{ value: "title", label: "Title" },
{ value: "owner", label: "Owner" },
];
const filteredDocuments = computed(() => {
const query = documentSearch.value.trim().toLowerCase();
const list = documents.value
.filter((item) => {
if (!query) return true;
const haystack = [item.title, item.summary, item.owner, formatDocumentScope(item.scope), item.body].join(" ").toLowerCase();
return haystack.includes(query);
})
.sort((a, b) => {
if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner);
return b.updatedAt.localeCompare(a.updatedAt);
});
return list;
});
const selectedDocumentId = ref(documents.value[0]?.id ?? "");
watchEffect(() => {
if (!filteredDocuments.value.length) {
selectedDocumentId.value = "";
return;
}
if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
});
const selectedDocument = computed(() => documents.value.find((item) => item.id === selectedDocumentId.value));
function updateSelectedDocumentBody(value: string) {
if (!selectedDocument.value) return;
selectedDocument.value.body = value;
}
function openDocumentsTab(push = false) {
selectedTab.value = "documents";
focusedCalendarEventId.value = "";
if (!selectedDocumentId.value && filteredDocuments.value.length) {
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
syncPathFromUi(push);
}
async function deleteWorkspaceDocumentById(documentIdInput: string) {
const documentId = safeTrim(documentIdInput);
if (!documentId) return;
if (documentDeletingId.value === documentId) return;
const target = documents.value.find((doc) => doc.id === documentId);
const targetLabel = safeTrim(target?.title) || "this document";
if (process.client && !window.confirm(`Delete ${targetLabel}?`)) return;
documentDeletingId.value = documentId;
try {
await doDeleteWorkspaceDocument({ id: documentId });
documents.value = documents.value.filter((doc) => doc.id !== documentId);
clientTimelineItems.value = clientTimelineItems.value.filter((item) => {
const isDocumentEntry = String(item.contentType).toLowerCase() === "document";
if (!isDocumentEntry) return true;
return item.contentId !== documentId && item.document?.id !== documentId;
});
if (selectedDocumentId.value === documentId) {
selectedDocumentId.value = "";
}
} finally {
if (documentDeletingId.value === documentId) {
documentDeletingId.value = "";
}
}
}
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
const peopleVisibilityMode = ref<PeopleVisibilityMode>("all");
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "lastContact", label: "Last contact" },
{ value: "name", label: "Name" },
];
const peopleVisibilityOptions: Array<{ value: PeopleVisibilityMode; label: string }> = [
{ value: "all", label: "All" },
{ value: "hidden", label: "Hidden" },
];
const selectedDealId = ref(deals.value[0]?.id ?? "");
const selectedDealStepsExpanded = ref(false);
function contactInitials(name: string) {
const words = String(name ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return "?";
return words
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function avatarSrcForThread(thread: { id: string; avatar: string }) {
if (brokenAvatarByContactId.value[thread.id]) return "";
return String(thread.avatar ?? "").trim();
}
function markAvatarBroken(contactId: string) {
if (!contactId) return;
brokenAvatarByContactId.value = {
...brokenAvatarByContactId.value,
[contactId]: true,
};
}
const commThreads = computed(() => {
const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at));
const map = new Map<string, CommItem[]>();
for (const item of sorted) {
if (!map.has(item.contact)) {
map.set(item.contact, []);
}
map.get(item.contact)?.push(item);
}
const contactById = new Map(contacts.value.map((contact) => [contact.id, contact]));
const inboxesByContactId = new Map<string, ContactInbox[]>();
for (const inbox of contactInboxes.value) {
if (!inboxesByContactId.has(inbox.contactId)) {
inboxesByContactId.set(inbox.contactId, []);
}
inboxesByContactId.get(inbox.contactId)?.push(inbox);
}
const contactIds = new Set<string>([
...contacts.value.map((contact) => contact.id),
...contactInboxes.value.map((inbox) => inbox.contactId),
]);
return [...contactIds]
.map((contactId) => {
const contact = contactById.get(contactId);
const inboxes = inboxesByContactId.get(contactId) ?? [];
const contactName = contact?.name ?? inboxes[0]?.contactName ?? "";
const items = map.get(contactName) ?? [];
const last = items[items.length - 1];
const channels = [
...new Set([
...(contact?.channels ?? []),
...inboxes.map((inbox) => inbox.channel),
...items.map((item) => item.channel),
]),
] as CommItem["channel"][];
const inboxFallbackLast = inboxes
.map((inbox) => inbox.lastMessageAt || inbox.updatedAt)
.filter(Boolean)
.sort()
.at(-1);
return {
id: contactId,
contact: contactName,
avatar: contact?.avatar ?? "",
channels,
lastAt: last?.at ?? contact?.lastContactAt ?? inboxFallbackLast ?? "",
lastText: last?.text ?? "No messages yet",
items,
};
})
.filter((thread) => thread.contact)
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
});
const peopleContactList = computed(() => {
const query = peopleSearch.value.trim().toLowerCase();
const list = commThreads.value.filter((item) => {
if (!query) return true;
const haystack = [item.contact, ...(item.channels ?? [])].join(" ").toLowerCase();
return haystack.includes(query);
});
const byVisibility = list.filter((item) => {
if (peopleVisibilityMode.value === "all") return true;
return threadInboxes(item).some((inbox) => inbox.isHidden);
});
return byVisibility.sort((a, b) => {
if (peopleSortMode.value === "name") return a.contact.localeCompare(b.contact);
return b.lastAt.localeCompare(a.lastAt);
});
});
const peopleDealList = computed(() => {
const query = peopleSearch.value.trim().toLowerCase();
const list = deals.value.filter((deal) => {
if (!query) return true;
const haystack = [deal.title, deal.stage, deal.amount, deal.nextStep, deal.summary, deal.contact]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
return list.sort((a, b) => a.title.localeCompare(b.title));
});
const selectedCommThreadId = ref("");
watchEffect(() => {
if (!commThreads.value.length) {
selectedCommThreadId.value = "";
return;
}
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
const first = commThreads.value[0];
if (first) selectedCommThreadId.value = first.id;
}
});
const selectedCommThread = computed(() =>
commThreads.value.find((thread) => thread.id === selectedCommThreadId.value),
);
watch(
() => [
selectedTab.value,
peopleLeftMode.value,
peopleListMode.value,
selectedChatId.value,
calendarView.value,
calendarRouteToken(calendarView.value),
focusedCalendarEventId.value,
selectedContactId.value,
selectedDealId.value,
selectedDocumentId.value,
activeChangeSetId.value,
activeChangeStep.value,
],
() => {
if (process.server || uiPathSyncLocked.value) return;
syncPathFromUi(false);
},
);
const commSendChannel = ref<CommItem["channel"] | "">("");
const commPinnedOnly = ref(false);
const commDraft = ref("");
const commSending = ref(false);
const commRecording = ref(false);
const commTranscribing = ref(false);
const commMicError = ref("");
const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message");
const commQuickMenuOpen = ref(false);
const commPinContextMenu = ref<{
open: boolean;
x: number;
y: number;
entry: any | null;
}>({
open: false,
x: 0,
y: 0,
entry: null,
});
const commEventSaving = ref(false);
const commEventError = ref("");
const commEventMode = ref<"planned" | "logged">("planned");
const commEventForm = ref({
startDate: "",
startTime: "",
durationMinutes: 30,
});
const commDocumentForm = ref<{
title: string;
}>({
title: "",
});
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
const eventCloseOpen = ref<Record<string, boolean>>({});
const eventCloseDraft = ref<Record<string, string>>({});
const eventCloseSaving = ref<Record<string, boolean>>({});
const eventCloseError = ref<Record<string, string>>({});
const eventArchiveRecordingById = ref<Record<string, boolean>>({});
const eventArchiveTranscribingById = ref<Record<string, boolean>>({});
const eventArchiveMicErrorById = ref<Record<string, string>>({});
let eventArchiveMediaRecorder: MediaRecorder | null = null;
let eventArchiveRecorderStream: MediaStream | null = null;
let eventArchiveRecorderMimeType = "audio/webm";
let eventArchiveChunks: Blob[] = [];
let eventArchiveTargetEventId = "";
watch(selectedCommThreadId, () => {
stopEventArchiveRecording();
destroyAllCommCallWaves();
callTranscriptOpen.value = {};
callTranscriptLoading.value = {};
callTranscriptText.value = {};
callTranscriptError.value = {};
commPinnedOnly.value = false;
commDraft.value = "";
commRecording.value = false;
commTranscribing.value = false;
commMicError.value = "";
commComposerMode.value = "message";
commQuickMenuOpen.value = false;
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
commEventError.value = "";
commDocumentForm.value = { title: "" };
inboxToggleLoadingById.value = {};
eventCloseOpen.value = {};
eventCloseDraft.value = {};
eventCloseSaving.value = {};
eventCloseError.value = {};
eventArchiveRecordingById.value = {};
eventArchiveTranscribingById.value = {};
eventArchiveMicErrorById.value = {};
clientTimelineRequestToken += 1;
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
commSendChannel.value = preferred;
if (!selectedCommThread.value) {
clientTimelineItems.value = [];
return;
}
void refreshSelectedClientTimeline().catch(() => undefined);
});
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
if (!selectedCommThread.value) return [];
const items = selectedCommThread.value.channels.filter((channel) => channel !== "Phone");
return items;
});
const visibleThreadItems = computed(() => {
if (!selectedCommThread.value) return [];
return selectedCommThread.value.items;
});
const selectedCommPins = computed(() => {
if (!selectedCommThread.value) return [];
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
});
function threadInboxes(thread: { id: string }) {
return contactInboxes.value
.filter((inbox) => inbox.contactId === thread.id)
.sort((a, b) => {
const aTime = a.lastMessageAt || a.updatedAt;
const bTime = b.lastMessageAt || b.updatedAt;
return bTime.localeCompare(aTime);
});
}
function threadChannelLabel(thread: { id: string; channels: CommItem["channel"][] }) {
const visibleChannels = [...new Set(threadInboxes(thread).filter((inbox) => !inbox.isHidden).map((inbox) => inbox.channel))];
if (visibleChannels.length === 1) return visibleChannels[0];
if (visibleChannels.length > 1) return `${visibleChannels[0]} +${visibleChannels.length - 1}`;
const fallback = [...new Set(thread.channels.filter((channel) => channel !== "Phone"))];
if (fallback.length === 1) return fallback[0];
if (fallback.length > 1) return `${fallback[0]} +${fallback.length - 1}`;
return "No channel";
}
const selectedCommLifecycleEvents = computed(() => {
const nowMs = lifecycleNowMs.value;
return clientTimelineItems.value
.filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent)
.map((entry) => {
const event = entry.calendarEvent as CalendarEvent;
const phase = eventLifecyclePhase(event, nowMs);
return {
event,
phase,
timelineAt: entry.datetime,
};
})
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt));
});
const threadStreamItems = computed(() => {
const rows = clientTimelineItems.value
.map((entry) => {
if (entry.contentType === "message" && entry.message) {
return {
id: entry.id,
at: entry.datetime,
kind: entry.message.kind,
item: entry.message,
};
}
if (entry.contentType === "calendar_event" && entry.calendarEvent) {
const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
return {
id: entry.id,
at: entry.datetime,
kind: "eventLifecycle" as const,
event: entry.calendarEvent,
phase,
};
}
if (entry.contentType === "recommendation" && entry.recommendation) {
return {
id: entry.id,
at: entry.datetime,
kind: "recommendation" as const,
card: entry.recommendation,
};
}
if (entry.contentType === "document" && entry.document) {
return {
id: entry.id,
at: entry.datetime,
kind: "document" as const,
document: entry.document,
};
}
return null;
})
.filter((entry) => entry !== null) as Array<any>;
return rows.sort((a, b) => a.at.localeCompare(b.at));
});
watch(
() => threadStreamItems.value.map((entry: any) => `${entry.kind}:${entry.id}`).join("|"),
() => {
void syncCommCallWaves();
},
);
const selectedCommPinnedStream = computed(() => {
const pins = selectedCommPins.value.map((pin) => {
const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
const sourceItem =
[...visibleThreadItems.value]
.filter((item) => normalizePinText(item.text) === normalizedText)
.sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
return {
id: `pin-${pin.id}`,
kind: "pin" as const,
text: pin.text,
sourceItem,
};
});
const rank = (phase: EventLifecyclePhase) => {
if (phase === "awaiting_outcome") return 0;
if (phase === "due_soon") return 1;
if (phase === "scheduled") return 2;
return 3;
};
const events = selectedCommLifecycleEvents.value
.filter((item) => !isEventFinalStatus(item.event.isArchived))
.sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start))
.map((item) => ({
id: `event-${item.event.id}`,
kind: "eventLifecycle" as const,
event: item.event,
phase: item.phase,
}));
return [...pins, ...events];
});
const latestPinnedItem = computed(() => selectedCommPinnedStream.value[0] ?? null);
const latestPinnedLabel = computed(() => {
if (!latestPinnedItem.value) return "No pinned items yet";
if (latestPinnedItem.value.kind === "pin") return stripPinnedPrefix(latestPinnedItem.value.text);
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
});
function normalizePinText(value: string) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function stripPinnedPrefix(value: string) {
return String(value ?? "").replace(/^\s*(закреплено|pinned)\s*:\s*/i, "").trim();
}
function isPinnedText(contact: string, value: string) {
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return false;
return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text);
}
function entryPinText(entry: any): string {
if (!entry) return "";
if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? ""));
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
if (entry.kind === "eventLifecycle") {
return normalizePinText(entry.event?.note || entry.event?.title || "");
}
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
return normalizePinText(entry.item?.text || "");
}
function closeCommPinContextMenu() {
commPinContextMenu.value = {
open: false,
x: 0,
y: 0,
entry: null,
};
}
function openCommPinContextMenu(event: MouseEvent, entry: any) {
const text = entryPinText(entry);
if (!text) return;
const menuWidth = 136;
const menuHeight = 46;
const padding = 8;
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
const x = Math.min(maxX, Math.max(padding, event.clientX));
const y = Math.min(maxY, Math.max(padding, event.clientY));
commPinContextMenu.value = {
open: true,
x,
y,
entry,
};
}
function isPinnedEntry(entry: any) {
const contact = selectedCommThread.value?.contact ?? "";
const text = entryPinText(entry);
return isPinnedText(contact, text);
}
const commPinContextActionLabel = computed(() => {
const entry = commPinContextMenu.value.entry;
if (!entry) return "Pin";
return isPinnedEntry(entry) ? "Unpin" : "Pin";
});
async function applyCommPinContextAction() {
const entry = commPinContextMenu.value.entry;
if (!entry) return;
closeCommPinContextMenu();
await togglePinForEntry(entry);
}
function onWindowPointerDownForCommPinMenu(event: PointerEvent) {
if (!commPinContextMenu.value.open) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".comm-pin-context-menu")) return;
closeCommPinContextMenu();
}
function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) {
if (!commPinContextMenu.value.open) return;
if (event.key === "Escape") {
closeCommPinContextMenu();
}
}
function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: EventLifecyclePhase }) {
if (entry.kind !== "eventLifecycle" || !entry.event) return false;
return !isEventFinalStatus(entry.event.isArchived);
}
function isEventCloseOpen(eventId: string) {
return Boolean(eventCloseOpen.value[eventId]);
}
function toggleEventClose(eventId: string) {
const next = !eventCloseOpen.value[eventId];
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: next };
if (next && !eventCloseDraft.value[eventId]) {
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
}
if (!next && eventCloseError.value[eventId]) {
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
}
}
function isEventArchiveRecording(eventId: string) {
return Boolean(eventArchiveRecordingById.value[eventId]);
}
function isEventArchiveTranscribing(eventId: string) {
return Boolean(eventArchiveTranscribingById.value[eventId]);
}
async function startEventArchiveRecording(eventId: string) {
if (eventArchiveMediaRecorder || isEventArchiveTranscribing(eventId)) return;
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "" };
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
eventArchiveRecorderStream = stream;
eventArchiveRecorderMimeType = recorder.mimeType || "audio/webm";
eventArchiveMediaRecorder = recorder;
eventArchiveChunks = [];
eventArchiveTargetEventId = eventId;
eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [eventId]: true };
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) eventArchiveChunks.push(event.data);
};
recorder.onstop = async () => {
const targetId = eventArchiveTargetEventId;
eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [targetId]: false };
eventArchiveMediaRecorder = null;
eventArchiveTargetEventId = "";
if (eventArchiveRecorderStream) {
eventArchiveRecorderStream.getTracks().forEach((track) => track.stop());
eventArchiveRecorderStream = null;
}
const audioBlob = new Blob(eventArchiveChunks, { type: eventArchiveRecorderMimeType });
eventArchiveChunks = [];
if (!targetId || audioBlob.size === 0) return;
eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: true };
try {
const text = await transcribeAudioBlob(audioBlob);
if (!text) {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: "Could not recognize speech" };
return;
}
const previous = String(eventCloseDraft.value[targetId] ?? "").trim();
const merged = previous ? `${previous} ${text}` : text;
eventCloseDraft.value = { ...eventCloseDraft.value, [targetId]: merged };
} catch (error: any) {
eventArchiveMicErrorById.value = {
...eventArchiveMicErrorById.value,
[targetId]: String(error?.data?.message ?? error?.message ?? "Voice transcription failed"),
};
} finally {
eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: false };
}
};
recorder.start();
} catch {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "No microphone access" };
}
}
function stopEventArchiveRecording() {
if (!eventArchiveMediaRecorder || eventArchiveMediaRecorder.state === "inactive") return;
eventArchiveMediaRecorder.stop();
}
function toggleEventArchiveRecording(eventId: string) {
if (!pilotMicSupported.value) {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" };
return;
}
if (isEventArchiveRecording(eventId)) {
stopEventArchiveRecording();
return;
}
void startEventArchiveRecording(eventId);
}
async function archiveEventManually(event: CalendarEvent) {
const eventId = event.id;
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
if (eventCloseSaving.value[eventId]) return;
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true };
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
try {
await doArchiveCalendarEvent({
input: {
id: eventId,
archiveNote: archiveNote || undefined,
},
});
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false };
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
} catch (error: any) {
eventCloseError.value = { ...eventCloseError.value, [eventId]: String(error?.message ?? error ?? "Failed to archive event") };
} finally {
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: false };
}
}
async function togglePinnedText(contact: string, value: string) {
if (commPinToggling.value) return;
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return;
commPinToggling.value = true;
try {
await doToggleContactPin({ contact: contactName, text });
} finally {
commPinToggling.value = false;
}
}
async function togglePinForEntry(entry: any) {
const contact = selectedCommThread.value?.contact ?? "";
const text = entryPinText(entry);
await togglePinnedText(contact, text);
}
const selectedWorkspaceContact = computed(() => {
if (selectedContact.value) return selectedContact.value;
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
if (threadContactId) {
const byId = contacts.value.find((contact) => contact.id === threadContactId);
if (byId) return byId;
}
const threadContactName = (selectedCommThread.value?.contact ?? "").trim();
if (threadContactName) {
const byName = contacts.value.find((contact) => contact.name === threadContactName);
if (byName) return byName;
}
return contacts.value[0] ?? null;
});
const contactRightPanelMode = ref<"summary" | "documents">("summary");
const contactDocumentsSearch = ref("");
const selectedWorkspaceContactDocuments = computed(() => {
const contact = selectedWorkspaceContact.value;
if (!contact) return [];
return documents.value
.filter((doc) => isDocumentLinkedToContact(doc.scope, contact))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
});
const filteredSelectedWorkspaceContactDocuments = computed(() => {
const query = contactDocumentsSearch.value.trim().toLowerCase();
if (!query) return selectedWorkspaceContactDocuments.value;
return selectedWorkspaceContactDocuments.value.filter((doc) => {
const haystack = [doc.title, doc.summary, doc.owner, formatDocumentScope(doc.scope), doc.body].join(" ").toLowerCase();
return haystack.includes(query);
});
});
watch(
() => selectedWorkspaceContact.value?.id ?? "",
() => {
contactRightPanelMode.value = "summary";
contactDocumentsSearch.value = "";
},
);
const selectedWorkspaceDeal = computed(() => {
const explicit = deals.value.find((deal) => deal.id === selectedDealId.value);
if (explicit) return explicit;
if (selectedWorkspaceContact.value) {
const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name);
if (linked) return linked;
}
return null;
});
function formatDealHeadline(deal: Deal) {
const title = safeTrim(deal.title);
const amountRaw = safeTrim(deal.amount);
if (!amountRaw) return title;
const normalized = amountRaw.replace(/\s+/g, "").replace(",", ".");
if (/^\d+(\.\d+)?$/.test(normalized)) {
return `${title} за ${new Intl.NumberFormat("ru-RU").format(Number(normalized))} $`;
}
return `${title} за ${amountRaw}`;
}
function getDealCurrentStep(deal: Deal) {
if (!deal.steps?.length) return null;
if (deal.currentStepId) {
const explicit = deal.steps.find((step) => step.id === deal.currentStepId);
if (explicit) return explicit;
}
const inProgress = deal.steps.find((step) => step.status === "in_progress");
if (inProgress) return inProgress;
const nextTodo = deal.steps.find((step) => step.status !== "done");
return nextTodo ?? deal.steps[deal.steps.length - 1];
}
function getDealCurrentStepLabel(deal: Deal) {
return safeTrim(getDealCurrentStep(deal)?.title) || safeTrim(deal.nextStep) || safeTrim(deal.stage) || "Без шага";
}
function parseDateFromText(input: string) {
const text = input.trim();
if (!text) return null;
const isoMatch = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
if (isoMatch) {
const [, y, m, d] = isoMatch;
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
if (!Number.isNaN(parsed.getTime())) return parsed;
}
const ruMatch = text.match(/\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b/);
if (ruMatch) {
const [, d, m, y] = ruMatch;
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function pluralizeRuDays(days: number) {
const mod10 = days % 10;
const mod100 = days % 100;
if (mod10 === 1 && mod100 !== 11) return "день";
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "дня";
return "дней";
}
function formatDealDeadline(dueDate: Date) {
const today = new Date();
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const startOfDue = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate());
const dayDiff = Math.round((startOfDue.getTime() - startOfToday.getTime()) / 86_400_000);
if (dayDiff < 0) {
const overdue = Math.abs(dayDiff);
return `просрочено на ${overdue} ${pluralizeRuDays(overdue)}`;
}
if (dayDiff === 0) return "сегодня";
if (dayDiff === 1) return "завтра";
return `через ${dayDiff} ${pluralizeRuDays(dayDiff)}`;
}
function isDealStepDone(step: DealStep) {
return step.status === "done";
}
function formatDealStepMeta(step: DealStep) {
if (step.status === "done") return "выполнено";
if (step.status === "blocked") return "заблокировано";
if (!step.dueAt) {
if (step.status === "in_progress") return "в работе";
return "без дедлайна";
}
const parsed = new Date(step.dueAt);
if (Number.isNaN(parsed.getTime())) return "без дедлайна";
return formatDealDeadline(parsed);
}
function formatYearMonthFirst(item: { first?: CalendarEvent }) {
if (!item.first) return "";
return `${formatDay(item.first.start)} · ${item.first.title}`;
}
const selectedWorkspaceDealDueDate = computed(() => {
const deal = selectedWorkspaceDeal.value;
if (!deal) return null;
const currentStep = getDealCurrentStep(deal);
if (currentStep?.dueAt) {
const parsed = new Date(currentStep.dueAt);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
const fromNextStep = parseDateFromText(currentStep?.title || deal.nextStep);
if (fromNextStep) return fromNextStep;
const now = Date.now();
const contactEvents = sortedEvents.value
.filter((event) => event.contact === deal.contact)
.map((event) => new Date(event.start))
.filter((date) => !Number.isNaN(date.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
const nextUpcoming = contactEvents.find((date) => date.getTime() >= now);
if (nextUpcoming) return nextUpcoming;
return contactEvents.length ? contactEvents[contactEvents.length - 1] : null;
});
const selectedWorkspaceDealSubtitle = computed(() => {
const deal = selectedWorkspaceDeal.value;
if (!deal) return "";
const stepLabel = getDealCurrentStepLabel(deal);
const dueDate = selectedWorkspaceDealDueDate.value;
if (!dueDate) return `${stepLabel} · без дедлайна`;
return `${stepLabel} · ${formatDealDeadline(dueDate)}`;
});
const selectedWorkspaceDealSteps = computed(() => {
const deal = selectedWorkspaceDeal.value;
if (!deal?.steps?.length) return [];
return [...deal.steps].sort((a, b) => a.order - b.order);
});
function calendarScopeLabel() {
if (focusedCalendarEvent.value) {
return `Календарь: ${focusedCalendarEvent.value.title}`;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
return `Календарь: ${monthLabel.value}`;
}
if (calendarView.value === "year") {
return `Календарь: ${calendarCursor.value.getFullYear()}`;
}
if (calendarView.value === "week") {
return `Календарь: ${calendarPeriodLabel.value}`;
}
return `Календарь: ${formatDay(`${selectedDateKey.value}T00:00:00`)}`;
}
function contextScopeLabel(scope: ContextScope) {
if (scope === "summary") return "Summary";
if (scope === "deal") return "Сделка";
if (scope === "message") return "Работа с пользователем";
return calendarScopeLabel();
}
const contextScopeChips = computed(() =>
contextScopes.value.map((scope) => ({
scope,
label: contextScopeLabel(scope),
})),
);
function buildContextPayload(): PilotContextPayload | null {
const scopes = [...contextScopes.value];
if (!scopes.length) return null;
const payload: PilotContextPayload = { scopes };
if (hasContextScope("summary") && selectedWorkspaceContact.value) {
payload.summary = {
contactId: selectedWorkspaceContact.value.id,
name: selectedWorkspaceContact.value.name,
};
}
if (hasContextScope("deal") && selectedWorkspaceDeal.value) {
payload.deal = {
dealId: selectedWorkspaceDeal.value.id,
title: selectedWorkspaceDeal.value.title,
contact: selectedWorkspaceDeal.value.contact,
};
}
if (hasContextScope("message")) {
payload.message = {
contactId: selectedWorkspaceContact.value?.id || undefined,
contact: selectedWorkspaceContact.value?.name || selectedCommThread.value?.contact || undefined,
intent: "add_message_or_reminder",
};
}
if (hasContextScope("calendar")) {
const eventIds = (() => {
if (focusedCalendarEvent.value) return [focusedCalendarEvent.value.id];
if (calendarView.value === "day") return selectedDayEvents.value.map((event) => event.id);
if (calendarView.value === "week") return weekDays.value.flatMap((d) => d.events.map((event) => event.id));
if (calendarView.value === "month" || calendarView.value === "agenda") {
const monthStart = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 1);
const monthEnd = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth() + 1, 1);
return sortedEvents.value
.filter((event) => {
const d = new Date(event.start);
return d >= monthStart && d < monthEnd;
})
.map((event) => event.id);
}
return sortedEvents.value
.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear())
.map((event) => event.id);
})();
payload.calendar = {
view: calendarView.value,
period: calendarPeriodLabel.value,
selectedDateKey: selectedDateKey.value,
focusedEventId: focusedCalendarEvent.value?.id || undefined,
eventIds,
};
}
return payload;
}
watch(
() => selectedWorkspaceDeal.value?.id ?? "",
() => {
selectedDealStepsExpanded.value = false;
},
);
async function transcribeCallItem(item: CommItem) {
const itemId = item.id;
if (callTranscriptLoading.value[itemId]) return;
if (callTranscriptText.value[itemId]) return;
if (Array.isArray(item.transcript) && item.transcript.length) {
const persisted = item.transcript.map((line) => String(line ?? "").trim()).filter(Boolean).join("\n");
if (persisted) {
callTranscriptText.value[itemId] = persisted;
return;
}
}
const audioUrl = getCallAudioUrl(item);
if (!audioUrl) {
callTranscriptError.value[itemId] = "Audio source is missing";
return;
}
callTranscriptLoading.value[itemId] = true;
callTranscriptError.value[itemId] = "";
try {
const audioBlob = await fetch(audioUrl).then((res) => {
if (!res.ok) throw new Error(`Audio fetch failed: ${res.status}`);
return res.blob();
});
const text = await transcribeAudioBlob(audioBlob);
callTranscriptText.value[itemId] = text || "(empty transcript)";
await doUpdateCommunicationTranscript({ id: itemId, transcript: text ? [text] : [] });
} catch (error: any) {
callTranscriptError.value[itemId] = String(error?.message ?? error ?? "Transcription failed");
} finally {
callTranscriptLoading.value[itemId] = false;
}
}
function toggleCallTranscript(item: CommItem) {
const itemId = item.id;
const next = !callTranscriptOpen.value[itemId];
callTranscriptOpen.value[itemId] = next;
if (next) {
void transcribeCallItem(item);
}
}
function isCallTranscriptOpen(itemId: string) {
return Boolean(callTranscriptOpen.value[itemId]);
}
async function toggleCommCallPlayback(item: CommItem) {
if (!isCommCallPlayable(item)) return;
const itemId = item.id;
await ensureCommCallWave(itemId);
const ws = commCallWaveSurfers.get(itemId);
if (!ws) return;
if (isCommCallPlaying(itemId)) {
ws.pause?.();
return;
}
pauseOtherCommCallWaves(itemId);
await ws.play?.();
}
function channelIcon(channel: "All" | CommItem["channel"]) {
if (channel === "All") return "all";
if (channel === "Telegram") return "telegram";
if (channel === "WhatsApp") return "whatsapp";
if (channel === "Instagram") return "instagram";
if (channel === "Email") return "email";
return "phone";
}
function formatInboxLabel(inbox: ContactInbox) {
const title = String(inbox.title ?? "").trim();
if (title) return `${inbox.channel} · ${title}`;
const source = String(inbox.sourceExternalId ?? "").trim();
if (!source) return inbox.channel;
const tail = source.length > 18 ? source.slice(-18) : source;
return `${inbox.channel} · ${tail}`;
}
function isInboxToggleLoading(inboxId: string) {
return Boolean(inboxToggleLoadingById.value[inboxId]);
}
async function setInboxHidden(inboxId: string, hidden: boolean) {
const id = String(inboxId ?? "").trim();
if (!id || isInboxToggleLoading(id)) return;
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: true };
try {
await doSetContactInboxHidden({ inboxId: id, hidden });
} catch (e: unknown) {
console.error("[setInboxHidden] mutation failed:", e);
} finally {
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
}
}
function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" {
if (item.kind !== "message" || item.direction !== "out") return "none";
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
if (rawStatus === "FAILED") return "failed";
if (rawStatus === "READ" || rawStatus === "DELIVERED") return "delivered";
if (rawStatus === "SENT") return "sent";
return "sending";
}
function messageDeliveryLabel(item: CommItem) {
const state = messageDeliveryUiState(item);
if (state === "failed") return "Delivery failed";
if (state === "delivered") return "Delivered";
if (state === "sent") return "Sent";
if (state === "sending") return "Sending";
return "";
}
function makeId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
function pushPilotNote(text: string) {
// Fire-and-forget: log assistant note to the same conversation.
doLogPilotNote({ text })
.then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
.catch(() => {});
}
function openCommunicationThread(contact: string) {
setPeopleLeftMode("contacts", true);
peopleListMode.value = "contacts";
selectedDealStepsExpanded.value = false;
const linkedContact = contacts.value.find((item) => item.name === contact);
if (linkedContact) {
selectedContactId.value = linkedContact.id;
}
const linkedDeal = deals.value.find((deal) => deal.contact === contact);
if (linkedDeal) {
selectedDealId.value = linkedDeal.id;
}
const thread = commThreads.value.find((item) => item.contact === contact);
if (thread) {
selectedCommThreadId.value = thread.id;
}
}
function openDealThread(deal: Deal) {
selectedDealId.value = deal.id;
peopleListMode.value = "deals";
selectedDealStepsExpanded.value = false;
openCommunicationThread(deal.contact);
peopleListMode.value = "deals";
}
function openThreadFromCalendarItem(event: CalendarEvent) {
if (!event.contact?.trim()) {
setPeopleLeftMode("calendar", true);
pickDate(event.start.slice(0, 10));
focusedCalendarEventId.value = event.id;
syncPathFromUi(true);
return;
}
openCommunicationThread(event.contact);
}
function openEventFromContact(event: CalendarEvent) {
setPeopleLeftMode("calendar", true);
pickDate(event.start.slice(0, 10));
focusedCalendarEventId.value = event.id;
syncPathFromUi(true);
}
function openMessageFromContact(channel: CommItem["channel"]) {
if (!selectedContact.value) return;
openCommunicationThread(selectedContact.value.name);
commSendChannel.value = channel;
}
function setDefaultCommEventForm(mode: "planned" | "logged") {
const start = mode === "planned"
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
commEventForm.value = {
startDate: toInputDate(start),
startTime: toInputTime(start),
durationMinutes: 30,
};
}
function setDefaultCommDocumentForm() {
commDocumentForm.value = {
title: "",
};
}
function openCommEventModal(mode: "planned" | "logged") {
if (!selectedCommThread.value) return;
commEventMode.value = mode;
setDefaultCommEventForm(mode);
commEventError.value = "";
commComposerMode.value = mode;
commQuickMenuOpen.value = false;
}
function openCommDocumentModal() {
if (!selectedCommThread.value) return;
setDefaultCommDocumentForm();
commEventError.value = "";
commComposerMode.value = "document";
commQuickMenuOpen.value = false;
}
function closeCommEventModal() {
if (commEventSaving.value) return;
commComposerMode.value = "message";
commEventError.value = "";
setDefaultCommDocumentForm();
commQuickMenuOpen.value = false;
}
function toggleCommQuickMenu() {
if (!selectedCommThread.value || commEventSaving.value) return;
commQuickMenuOpen.value = !commQuickMenuOpen.value;
}
function closeCommQuickMenu() {
commQuickMenuOpen.value = false;
}
function commComposerPlaceholder() {
if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать...";
if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию...";
if (commComposerMode.value === "document") return "Опиши документ или вложение для контакта...";
return "Type a message...";
}
function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) {
const cleaned = text.replace(/\s+/g, " ").trim();
if (cleaned) {
const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
if (sentence) return sentence.slice(0, 120);
}
return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`;
}
function buildCommDocumentTitle(text: string, contact: string) {
const cleaned = text.replace(/\s+/g, " ").trim();
if (cleaned) {
const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
if (sentence) return sentence.slice(0, 120);
}
return `Документ для ${contact}`;
}
async function createCommEvent() {
if (!selectedCommThread.value || commEventSaving.value) return;
const note = commDraft.value.trim();
const title = buildCommEventTitle(note, commEventMode.value, selectedCommThread.value.contact);
const duration = Number(commEventForm.value.durationMinutes || 0);
if (!note) {
commEventError.value = "Текст события обязателен";
return;
}
if (!commEventForm.value.startDate || !commEventForm.value.startTime) {
commEventError.value = "Date and time are required";
return;
}
const start = new Date(`${commEventForm.value.startDate}T${commEventForm.value.startTime}:00`);
if (Number.isNaN(start.getTime())) {
commEventError.value = "Invalid date or time";
return;
}
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 30;
const end = new Date(start);
end.setMinutes(end.getMinutes() + safeDuration);
commEventSaving.value = true;
commEventError.value = "";
try {
const res = await doCreateCalendarEvent({
input: {
title,
start: start.toISOString(),
end: end.toISOString(),
contact: selectedCommThread.value.contact,
note,
archived: commEventMode.value === "logged",
archiveNote: commEventMode.value === "logged" ? note : undefined,
},
});
if (res?.data?.createCalendarEvent) {
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
}
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
commDraft.value = "";
commComposerMode.value = "message";
commEventError.value = "";
} catch (error: any) {
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
} finally {
commEventSaving.value = false;
}
}
async function createCommDocument() {
if (!selectedCommThread.value || commEventSaving.value) return;
const summary = commDraft.value.trim();
if (!summary) {
commEventError.value = "Текст документа обязателен";
return;
}
const title = safeTrim(commDocumentForm.value.title) || buildCommDocumentTitle(summary, selectedCommThread.value.contact);
const scope = buildContactDocumentScope(selectedCommThread.value.id, selectedCommThread.value.contact);
const body = summary;
commEventSaving.value = true;
commEventError.value = "";
try {
const res = await doCreateWorkspaceDocument({
input: {
title,
owner: authDisplayName.value,
scope,
summary,
body,
},
});
const created = res?.data?.createWorkspaceDocument;
if (created) {
documents.value = [created as WorkspaceDocument, ...documents.value.filter((doc) => doc.id !== created.id)];
selectedDocumentId.value = created.id;
} else {
selectedDocumentId.value = "";
}
contactRightPanelMode.value = "documents";
commDraft.value = "";
commComposerMode.value = "message";
commEventError.value = "";
setDefaultCommDocumentForm();
} catch (error: any) {
commEventError.value = String(error?.message ?? error ?? "Failed to create document");
} finally {
commEventSaving.value = false;
}
}
async function sendCommMessage() {
const text = commDraft.value.trim();
if (!text || commSending.value || !selectedCommThread.value) return;
commSending.value = true;
try {
const channel = commSendChannel.value;
if (!channel) return;
await doCreateCommunication({
input: {
contact: selectedCommThread.value.contact,
channel,
kind: "message",
direction: "out",
text,
},
});
commDraft.value = "";
openCommunicationThread(selectedCommThread.value.contact);
} finally {
commSending.value = false;
}
}
function onCommDictationTranscript(text: string) {
const next = String(text ?? "").trim();
if (!next) return;
const previous = String(commDraft.value ?? "").trim();
commDraft.value = previous ? `${previous} ${next}` : next;
commMicError.value = "";
}
function handleCommComposerEnter(event: KeyboardEvent) {
if (event.shiftKey) return;
event.preventDefault();
handleCommComposerSubmit();
}
function handleCommComposerSubmit() {
if (commComposerMode.value === "message") {
void sendCommMessage();
return;
}
if (commComposerMode.value === "document") {
void createCommDocument();
return;
}
void createCommEvent();
}
async function executeFeedAction(card: FeedCard) {
const key = card.proposal.key;
if (key === "create_followup") {
const start = new Date();
start.setMinutes(start.getMinutes() + 30);
start.setSeconds(0, 0);
const end = new Date(start);
end.setMinutes(end.getMinutes() + 30);
const res = await doCreateCalendarEvent({
input: {
title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
start: start.toISOString(),
end: end.toISOString(),
contact: card.contact,
note: "Created from feed action.",
},
});
if (res?.data?.createCalendarEvent) {
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
}
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
setPeopleLeftMode("calendar", true);
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
}
if (key === "open_comm") {
openCommunicationThread(card.contact);
return `Opened ${card.contact} communication thread.`;
}
if (key === "call") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
durationSec: 0,
},
});
openCommunicationThread(card.contact);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
openCommunicationThread(card.contact);
return `Draft message added to ${card.contact} communications.`;
}
if (key === "run_summary") {
return "Call summary prepared: 5 next steps sent to Pilot.";
}
if (key === "prepare_question") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
},
});
openCommunicationThread(card.contact);
return `Question about decision date added to ${card.contact} chat.`;
}
return "Action completed.";
}
async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
card.decision = decision;
if (decision === "rejected") {
const note = "Rejected. Nothing created.";
card.decisionNote = note;
await doUpdateFeedDecision({ id: card.id, decision: "rejected", decisionNote: note });
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = await executeFeedAction(card);
card.decisionNote = result;
await doUpdateFeedDecision({ id: card.id, decision: "accepted", decisionNote: result });
pushPilotNote(`[${card.contact}] ${result}`);
}
</script>
<template>
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
<CrmAuthLoading v-if="!authResolved || !authMe" />
<template v-else>
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
<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="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"
:on-pilot-input="(value) => { pilotInput = value; }"
: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">
<CrmWorkspaceTopbar
:selected-tab="selectedTab"
:people-left-mode="peopleLeftMode"
:auth-initials="authInitials"
:auth-display-name="authDisplayName"
:telegram-status-badge-class="telegramStatusBadgeClass"
:telegram-status-label="telegramStatusLabel"
:telegram-connect-busy="telegramConnectBusy"
:telegram-connect-notice="telegramConnectNotice"
@open-contacts="setPeopleLeftMode('contacts', true)"
@open-calendar="setPeopleLeftMode('calendar', true)"
@open-documents="openDocumentsTab(true)"
@start-telegram-connect="startTelegramBusinessConnect"
@logout="logout"
/>
<div
class="min-h-0 flex-1"
:class="selectedTab === 'documents' || (selectedTab === 'communications' && peopleLeftMode === 'contacts') ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
>
<CrmCalendarPanel
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
:context-picker-enabled="contextPickerEnabled"
:has-context-scope="hasContextScope"
:toggle-context-scope="toggleContextScope"
:context-scope-label="contextScopeLabel"
:set-today="setToday"
:calendar-period-label="calendarPeriodLabel"
:calendar-zoom-level-index="calendarZoomLevelIndex"
:on-calendar-zoom-slider-input="onCalendarZoomSliderInput"
:focused-calendar-event="focusedCalendarEvent"
:format-day="formatDay"
:format-time="formatTime"
:set-calendar-content-wrap-ref="setCalendarContentWrapRef"
:shift-calendar="shiftCalendar"
:set-calendar-content-scroll-ref="setCalendarContentScrollRef"
:on-calendar-hierarchy-wheel="onCalendarHierarchyWheel"
:set-calendar-scene-ref="setCalendarSceneRef"
:calendar-viewport-height="calendarViewportHeight"
:normalized-calendar-view="normalizedCalendarView"
:calendar-fly-visible="calendarFlyVisible"
:set-calendar-fly-rect-ref="setCalendarFlyRectRef"
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
:calendar-view="calendarView"
:year-months="yearMonths"
:calendar-cursor-month="calendarCursor.getMonth()"
:calendar-hovered-month-index="calendarHoveredMonthIndex"
:set-calendar-hovered-month-index="setCalendarHoveredMonthIndex"
:calendar-zoom-prime-token="calendarZoomPrimeToken"
:calendar-prime-month-token="calendarPrimeMonthToken"
:calendar-prime-style="calendarPrimeStyle"
:zoom-to-month="zoomToMonth"
:open-thread-from-calendar-item="openThreadFromCalendarItem"
:month-rows="monthRows"
:calendar-hovered-week-start-key="calendarHoveredWeekStartKey"
:set-calendar-hovered-week-start-key="setCalendarHoveredWeekStartKey"
:calendar-prime-week-token="calendarPrimeWeekToken"
:selected-date-key="selectedDateKey"
:month-cell-has-focused-event="monthCellHasFocusedEvent"
:calendar-hovered-day-key="calendarHoveredDayKey"
:set-calendar-hovered-day-key="setCalendarHoveredDayKey"
:pick-date="pickDate"
:month-cell-events="monthCellEvents"
:is-review-highlighted-event="isReviewHighlightedEvent"
:week-days="weekDays"
:calendar-prime-day-token="calendarPrimeDayToken"
:selected-day-events="selectedDayEvents"
/>
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'contacts'" class="flex h-full min-h-0 flex-col gap-0">
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)_320px] md:grid-rows-[auto_minmax(0,1fr)]">
<CrmCommunicationsListSidebar
:people-list-mode="peopleListMode"
:people-search="peopleSearch"
:people-sort-options="peopleSortOptions"
:people-sort-mode="peopleSortMode"
:people-visibility-options="peopleVisibilityOptions"
:people-visibility-mode="peopleVisibilityMode"
:people-contact-list="peopleContactList"
:selected-comm-thread-id="selectedCommThreadId"
:is-review-highlighted-contact="isReviewHighlightedContact"
:open-communication-thread="openCommunicationThread"
:avatar-src-for-thread="avatarSrcForThread"
:mark-avatar-broken="markAvatarBroken"
:contact-initials="contactInitials"
:format-thread-time="formatThreadTime"
:thread-channel-label="threadChannelLabel"
:people-deal-list="peopleDealList"
:selected-deal-id="selectedDealId"
:is-review-highlighted-deal="isReviewHighlightedDeal"
:open-deal-thread="openDealThread"
:get-deal-current-step-label="getDealCurrentStepLabel"
:on-people-list-mode-change="(mode) => { peopleListMode = mode; }"
:on-people-search-input="(value) => { peopleSearch = value; }"
:on-people-sort-mode-change="(mode) => { peopleSortMode = mode; }"
:on-people-visibility-mode-change="(mode) => { peopleVisibilityMode = mode; }"
/>
<div class="hidden h-12 items-center justify-between gap-2 border-b border-base-300 px-3 md:flex md:col-span-2">
<div v-if="selectedWorkspaceContact">
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
</div>
<div v-else-if="selectedCommThread">
<p class="font-medium">{{ selectedCommThread.contact }}</p>
</div>
<div v-if="selectedCommThread" class="dropdown dropdown-end" @click.stop>
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
title="Source visibility settings"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M19.14 12.94a7.43 7.43 0 0 0 .05-.94 7.43 7.43 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.5.5 0 0 0 13.9 2h-3.8a.5.5 0 0 0-.49.41L9.25 4.95a7.2 7.2 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.72 8.47a.5.5 0 0 0 .12.63l2.03 1.58a7.43 7.43 0 0 0-.05.94c0 .31.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32c.13.23.39.32.6.22l2.39-.96c.5.39 1.05.71 1.62.94l.36 2.54c.04.24.25.41.49.41h3.8c.24 0 .45-.17.49-.41l.36-2.54c.57-.23 1.12-.55 1.62-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-1 w-60 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<p class="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Sources</p>
<div v-if="threadInboxes(selectedCommThread).length" class="space-y-1">
<button
v-for="inbox in threadInboxes(selectedCommThread)"
:key="`thread-header-inbox-setting-${inbox.id}`"
class="btn btn-ghost btn-xs h-auto min-h-0 w-full justify-between px-2 py-1 text-left normal-case"
@click.stop="setInboxHidden(inbox.id, !inbox.isHidden)"
>
<span class="min-w-0 truncate">{{ formatInboxLabel(inbox) }}</span>
<span class="shrink-0 text-[10px] text-base-content/70">
{{
isInboxToggleLoading(inbox.id)
? "..."
: inbox.isHidden
? "Hidden"
: "Visible"
}}
</span>
</button>
</div>
<p v-else class="px-1 py-1 text-[11px] text-base-content/60">No sources.</p>
</div>
</div>
</div>
<article class="h-full min-h-0 border-r border-base-300 flex flex-col">
<div v-if="false" class="p-3">
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(-1)">←</button>
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(1)">→</button>
</div>
<div class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end">
<select v-model="calendarView" class="select select-bordered select-xs w-36">
<option
v-for="option in calendarViewOptions"
:key="`workspace-right-calendar-view-${option.value}`"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<div v-if="calendarView === 'month'" class="mt-3 space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in monthCells"
:key="`workspace-right-month-${cell.key}`"
class="min-h-24 rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
]"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in cell.events.slice(0, 2)"
:key="`workspace-right-month-event-${event.id}`"
class="block w-full truncate text-left text-[10px] text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
<div v-else-if="calendarView === 'week'" class="mt-3 space-y-2">
<article
v-for="day in weekDays"
:key="`workspace-right-week-${day.key}`"
class="rounded-xl border border-base-300 p-3"
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
@click="pickDate(day.key)"
>
<p class="mb-2 text-sm font-semibold">{{ day.label }} {{ day.day }}</p>
<div class="space-y-1">
<button
v-for="event in day.events"
:key="`workspace-right-week-event-${event.id}`"
class="block w-full rounded bg-base-200 px-2 py-1 text-left text-xs hover:bg-base-300/80"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</button>
<p v-if="day.events.length === 0" class="text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
<div v-else-if="calendarView === 'day'" class="mt-3 space-y-2">
<button
v-for="event in selectedDayEvents"
:key="`workspace-right-day-event-${event.id}`"
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
<div v-else-if="calendarView === 'year'" class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in yearMonths"
:key="`workspace-right-year-${item.monthIndex}`"
class="rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5"
@click="openYearMonth(item.monthIndex)"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
{{ formatYearMonthFirst(item) }}
</p>
</button>
</div>
<div v-else class="mt-3 space-y-2">
<button
v-for="event in sortedEvents"
:key="`workspace-right-agenda-${event.id}`"
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
</div>
</div>
<div v-else-if="selectedCommThread" class="relative flex h-full min-h-0 flex-col">
<div class="comm-thread-surface min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-2">
<button
class="sticky top-0 z-10 -mx-3 mb-2 flex w-[calc(100%+1.5rem)] items-center gap-2 border-b border-base-300 bg-base-100/80 px-3 py-2 text-left backdrop-blur-sm transition hover:bg-base-100"
@click="commPinnedOnly = !commPinnedOnly"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
</svg>
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
</button>
<div
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
:key="entry.id"
@contextmenu.prevent="openCommPinContextMenu($event, entry)"
>
<div
v-if="entry.kind === 'pin'"
class="flex"
:class="entry.sourceItem ? (entry.sourceItem.direction === 'out' ? 'justify-end' : 'justify-start') : 'justify-center'"
>
<div
class="max-w-[88%] rounded-xl border border-base-300 p-3"
:class="entry.sourceItem?.direction === 'out' ? 'bg-base-200' : 'bg-base-100'"
>
<p class="text-sm">{{ stripPinnedPrefix(entry.text) }}</p>
<p class="mt-1 text-xs text-base-content/60">
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
</svg>
</span>
<span>{{ entry.sourceItem ? formatStamp(entry.sourceItem.at) : "Pinned" }}</span>
</p>
</div>
</div>
<div
v-else-if="entry.kind === 'call'"
class="flex"
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
>
<div
class="call-wave-card w-full max-w-[460px] rounded-2xl border border-base-300 px-4 py-3"
:class="[
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
]"
>
<p class="mb-2 text-xs text-base-content/65">
<span class="font-semibold">{{ entry.item.direction === "out" ? "You" : selectedCommThread?.contact || "Contact" }}</span>
<span class="mx-1">·</span>
{{ formatDay(entry.item.at) }} · {{ formatTime(entry.item.at) }}
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
</p>
<div class="comm-call-wave-wrap mb-2">
<div class="comm-call-wave" :ref="(el) => setCommCallWaveHost(entry.item.id, el as Element | null)" />
<button
class="call-wave-center-play"
:disabled="!isCommCallPlayable(entry.item)"
:title="isCommCallPlayable(entry.item) ? 'Play voice message' : 'Audio unavailable'"
@click="toggleCommCallPlayback(entry.item)"
>
<svg v-if="!isCommCallPlaying(entry.item.id)" viewBox="0 0 20 20" class="h-4 w-4">
<path
fill="currentColor"
d="M6.5 4.75a.75.75 0 0 1 1.12-.65l7.5 4.25a.75.75 0 0 1 0 1.3l-7.5 4.25a.75.75 0 0 1-1.12-.65v-8.5Z"
/>
</svg>
<svg v-else viewBox="0 0 20 20" class="h-4 w-4">
<path
fill="currentColor"
d="M6.75 4.5a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Zm6.5 0a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Z"
/>
</svg>
</button>
</div>
<div class="mt-2 flex" :class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'">
<button class="call-transcript-toggle" @click="toggleCallTranscript(entry.item)">
<span>
{{
callTranscriptLoading[entry.item.id]
? "Generating transcript..."
: isCallTranscriptOpen(entry.item.id)
? "Hide transcript"
: "Show transcript"
}}
</span>
<svg
viewBox="0 0 20 20"
class="h-3.5 w-3.5 transition-transform"
:class="isCallTranscriptOpen(entry.item.id) ? 'rotate-180' : ''"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.22 7.22a.75.75 0 0 1 1.06 0L10 10.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 8.28a.75.75 0 0 1 0-1.06Z"
/>
</svg>
</button>
</div>
<transition name="accordion">
<div v-if="isCallTranscriptOpen(entry.item.id)" class="mt-2 rounded-xl border border-base-300 bg-base-100 p-2 text-left">
<div v-if="callTranscriptLoading[entry.item.id]" class="call-transcript-loader" aria-live="polite" aria-busy="true">
<span />
<span />
<span />
<span />
</div>
<div v-else-if="callTranscriptError[entry.item.id]" class="space-y-2">
<p class="text-xs leading-relaxed text-error">
{{ callTranscriptError[entry.item.id] }}
</p>
<button class="btn btn-xs btn-outline" @click="transcribeCallItem(entry.item)">Retry</button>
</div>
<p v-else class="text-xs leading-relaxed text-base-content/80">
{{ callTranscriptText[entry.item.id] || "No transcript yet" }}
</p>
</div>
</transition>
</div>
</div>
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
<article
class="w-full max-w-[460px] rounded-xl border p-3 text-center"
:class="[eventPhaseToneClass(entry.phase), isReviewHighlightedEvent(entry.event.id) ? 'ring-2 ring-success/45' : '']"
>
<p class="text-xs text-base-content/70">
{{ eventRelativeLabel(entry.event, lifecycleNowMs) }} · {{ formatDay(entry.event.start) }} {{ formatTime(entry.event.start) }}
</p>
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
</button>
</div>
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
<textarea
v-model="eventCloseDraft[entry.event.id]"
class="textarea textarea-bordered w-full text-xs"
rows="3"
placeholder="Archive note (optional)"
/>
<div class="flex justify-between gap-2">
<button
class="btn btn-xs btn-outline"
:disabled="isEventArchiveTranscribing(entry.event.id)"
@click="toggleEventArchiveRecording(entry.event.id)"
>
{{
isEventArchiveTranscribing(entry.event.id)
? "Transcribing..."
: isEventArchiveRecording(entry.event.id)
? "Stop mic"
: "Voice note"
}}
</button>
</div>
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
<div class="flex justify-end">
<button
class="btn btn-xs"
:disabled="eventCloseSaving[entry.event.id]"
@click="archiveEventManually(entry.event)"
>
{{ eventCloseSaving[entry.event.id] ? "Saving..." : "Confirm archive" }}
</button>
</div>
</div>
</article>
</div>
<div v-else-if="entry.kind === 'document'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-left">
<p class="text-xs text-base-content/65">Document · {{ formatStamp(entry.at) }}</p>
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.document.title }}</p>
<p class="mt-1 text-xs text-base-content/70">
{{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }}
</p>
<p class="mt-2 text-sm text-base-content/85">{{ entry.document.summary }}</p>
</article>
</div>
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
<p class="text-sm">{{ entry.card.text }}</p>
<div class="mt-2 rounded-lg border border-base-300 bg-base-200/30 p-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/70">{{ entry.card.proposal.title }}</p>
<p
v-for="line in entry.card.proposal.details"
:key="`${entry.card.id}-${line}`"
class="mt-1 text-xs text-base-content/80"
>
{{ line }}
</p>
</div>
<div v-if="entry.card.decision === 'pending'" class="mt-2 flex gap-2">
<button class="btn btn-xs flex-1" @click="decideFeedCard(entry.card, 'accepted')">Yes</button>
<button class="btn btn-xs btn-outline flex-1" @click="decideFeedCard(entry.card, 'rejected')">No</button>
</div>
<p v-else class="mt-2 text-xs text-base-content/70">{{ entry.card.decisionNote }}</p>
</article>
</div>
<div
v-else
class="flex"
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[88%] rounded-xl border border-base-300 p-3"
:class="[
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
]"
>
<p class="text-sm">{{ entry.item.text }}</p>
<p class="mt-1 text-xs text-base-content/60">
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
<svg v-if="channelIcon(entry.item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
</svg>
</span>
<span>{{ formatStamp(entry.item.at) }}</span>
<span
v-if="messageDeliveryUiState(entry.item) !== 'none'"
class="ml-1 inline-flex items-center align-middle text-base-content/70"
:title="messageDeliveryLabel(entry.item)"
>
<span
v-if="messageDeliveryUiState(entry.item) === 'sending'"
class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-current border-t-transparent"
/>
<span
v-else-if="messageDeliveryUiState(entry.item) === 'sent'"
class="text-[10px] leading-none"
>
</span>
<span
v-else-if="messageDeliveryUiState(entry.item) === 'delivered'"
class="text-[10px] leading-none tracking-[-0.12em]"
>
✓✓
</span>
<span
v-else-if="messageDeliveryUiState(entry.item) === 'failed'"
class="text-[10px] font-semibold leading-none text-error"
>
!
</span>
</span>
</p>
</div>
</div>
</div>
</div>
<div
v-if="commPinContextMenu.open"
class="comm-pin-context-menu"
:style="{ left: `${commPinContextMenu.x}px`, top: `${commPinContextMenu.y}px` }"
@click.stop
>
<button
class="comm-pin-context-menu-item"
:disabled="commPinToggling"
@click="applyCommPinContextAction"
>
{{ commPinContextActionLabel }}
</button>
</div>
<div class="sticky bottom-0 z-10 mt-0 border-t border-base-300 bg-base-100/95 px-3 pt-3 backdrop-blur">
<div class="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
<div
class="dropdown dropdown-top dropdown-center"
:class="{ 'dropdown-open': commQuickMenuOpen }"
@focusout="closeCommQuickMenu"
>
<button
tabindex="0"
type="button"
class="btn btn-sm btn-circle border border-base-300 bg-base-100 text-base-content/85 hover:bg-base-200"
title="Add item"
@click.stop="toggleCommQuickMenu"
>
+
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm mb-2 w-56 rounded-xl border border-base-300 bg-base-100 p-2 shadow-xl">
<li>
<button @click="openCommEventModal('planned')">
Plan event
</button>
</li>
<li>
<button @click="openCommEventModal('logged')">
Log past event
</button>
</li>
<li>
<button @click="openCommDocumentModal">
Attach document
</button>
</li>
</ul>
</div>
</div>
<div
class="comm-input-wrap"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('message') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('message')"
>
<span v-if="contextPickerEnabled" class="context-scope-label">Работа с пользователем</span>
<div class="comm-input-shell">
<textarea
v-model="commDraft"
class="comm-input-textarea"
:placeholder="commComposerPlaceholder()"
:disabled="commSending || commEventSaving"
@keydown.enter="handleCommComposerEnter"
/>
<div v-if="commComposerMode === 'planned' || commComposerMode === 'logged'" class="comm-event-controls">
<input
v-model="commEventForm.startDate"
type="date"
class="input input-bordered input-xs h-7 min-h-7"
:disabled="commEventSaving"
>
<input
v-model="commEventForm.startTime"
type="time"
class="input input-bordered input-xs h-7 min-h-7"
:disabled="commEventSaving"
>
<select
v-model.number="commEventForm.durationMinutes"
class="select select-bordered select-xs h-7 min-h-7"
:disabled="commEventSaving"
>
<option :value="15">15m</option>
<option :value="30">30m</option>
<option :value="45">45m</option>
<option :value="60">60m</option>
<option :value="90">90m</option>
</select>
</div>
<div v-else-if="commComposerMode === 'document'" class="comm-event-controls">
<input
v-model="commDocumentForm.title"
type="text"
class="input input-bordered input-xs h-7 min-h-7 flex-1"
:disabled="commEventSaving"
placeholder="Document title (optional)"
>
</div>
<p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error">
{{ commEventError }}
</p>
<p v-if="commMicError" class="comm-mic-error text-xs text-error">
{{ commMicError }}
</p>
<div v-if="commComposerMode === 'message'" class="comm-input-channel dropdown dropdown-top not-prose">
<button
tabindex="0"
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
:disabled="commSending"
:title="`Channel: ${commSendChannel}`"
>
<span class="mr-1">{{ commSendChannel || "Channel" }}</span>
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
<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>
<ul tabindex="-1" class="dropdown-content menu menu-sm bg-base-200 rounded-box my-2 w-40 border border-base-300 shadow-2xl">
<li v-for="channel in commSendChannelOptions" :key="`comm-send-menu-${channel}`">
<button @click="commSendChannel = channel">
<span>{{ channel }}</span>
<span v-if="commSendChannel === channel">✓</span>
</button>
</li>
</ul>
</div>
<div class="comm-input-actions">
<button
v-if="commComposerMode !== 'message'"
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
:disabled="commEventSaving"
title="Back to message"
@click="closeCommEventModal"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 11H7.83l4.58-4.59L11 5l-7 7 7 7 1.41-1.41L7.83 13H20z" />
</svg>
</button>
<CrmVoiceDictationButton
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
:class="commRecording || commTranscribing ? 'comm-mic-active' : ''"
:disabled="commSending || commEventSaving"
:session-key="selectedCommThreadId"
idle-title="Voice input"
recording-title="Stop and insert transcript"
transcribing-title="Transcribing..."
@update:recording="commRecording = $event"
@update:transcribing="commTranscribing = $event"
@transcript="onCommDictationTranscript"
@error="commMicError = $event"
/>
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="commSending || commEventSaving || commRecording || commTranscribing || !commDraft.trim() || (commComposerMode === 'message' && !commSendChannel)"
:title="
commComposerMode === 'message'
? `Send via ${commSendChannel}`
: commComposerMode === 'logged'
? 'Save log event'
: commComposerMode === 'document'
? 'Save document'
: 'Create event'
"
@click="handleCommComposerSubmit"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? '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>
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No communication history.
</div>
</article>
<CrmCommunicationsContextSidebar
:selected-workspace-contact-documents="selectedWorkspaceContactDocuments"
:contact-right-panel-mode="contactRightPanelMode"
:on-contact-right-panel-mode-change="(mode) => { contactRightPanelMode = mode; }"
:selected-document-id="selectedDocumentId"
:on-selected-document-id-change="(documentId) => { selectedDocumentId = documentId; }"
:contact-documents-search="contactDocumentsSearch"
:on-contact-documents-search-input="(value) => { contactDocumentsSearch = value; }"
:filtered-selected-workspace-contact-documents="filteredSelectedWorkspaceContactDocuments"
:format-stamp="formatStamp"
:open-documents-tab="openDocumentsTab"
:selected-workspace-deal="selectedWorkspaceDeal"
:is-review-highlighted-deal="isReviewHighlightedDeal"
:context-picker-enabled="contextPickerEnabled"
:has-context-scope="hasContextScope"
:toggle-context-scope="toggleContextScope"
:format-deal-headline="formatDealHeadline"
:selected-workspace-deal-subtitle="selectedWorkspaceDealSubtitle"
:selected-workspace-deal-steps="selectedWorkspaceDealSteps"
:selected-deal-steps-expanded="selectedDealStepsExpanded"
:on-selected-deal-steps-expanded-change="(value) => { selectedDealStepsExpanded = value; }"
:is-deal-step-done="isDealStepDone"
:format-deal-step-meta="formatDealStepMeta"
:active-review-contact-diff="activeReviewContactDiff"
:selected-workspace-contact="selectedWorkspaceContact"
/>
</div>
</section>
<CrmDocumentsPanel
v-else-if="selectedTab === 'documents'"
:document-search="documentSearch"
:document-sort-mode="documentSortMode"
:document-sort-options="documentSortOptions"
:filtered-documents="filteredDocuments"
:selected-document-id="selectedDocumentId"
:selected-document="selectedDocument"
:format-document-scope="formatDocumentScope"
:format-stamp="formatStamp"
@update:document-search="documentSearch = $event"
@update:document-sort-mode="documentSortMode = $event"
@select-document="selectedDocumentId = $event"
@update-selected-document-body="updateSelectedDocumentBody"
@delete-document="deleteWorkspaceDocumentById"
/>
<CrmChangeReviewOverlay
:visible="reviewActive && selectedTab === 'communications'"
:active-change-step-number="activeChangeStepNumber"
:active-change-items="activeChangeItems"
:active-change-item="activeChangeItem"
:active-change-index="activeChangeIndex"
:rollbackable-count="rollbackableCount"
:change-action-busy="changeActionBusy"
:describe-change-entity="describeChangeEntity"
:describe-change-action="describeChangeAction"
@close="finishReview(true)"
@open-item-target="openChangeItemTarget"
@rollback-item="rollbackChangeItemById"
@rollback-all="rollbackSelectedChangeItems"
@prev-step="goToPreviousChangeStep"
@next-step="goToNextChangeStep"
@done="finishReview(true)"
/>
</div>
</div>
</main>
</div>
</template>
</div>
</template>
<style scoped>
.comm-input-wrap {
display: grid;
gap: 6px;
}
.comm-input-shell {
position: relative;
}
.comm-input-textarea {
width: 100%;
min-height: 96px;
resize: none;
border-radius: 0;
border: 0;
background: transparent;
color: var(--color-base-content);
padding: 10px 88px 36px 12px;
font-size: 13px;
line-height: 1.4;
}
.comm-event-controls {
position: absolute;
left: 10px;
bottom: 8px;
display: grid;
grid-template-columns: 118px 88px 64px;
gap: 6px;
align-items: center;
}
.comm-event-controls :is(input, select) {
font-size: 11px;
padding-inline: 8px;
}
.comm-event-error {
position: absolute;
left: 12px;
top: 8px;
}
.comm-mic-error {
position: absolute;
left: 12px;
top: 8px;
max-width: 65%;
line-height: 1.2;
}
.comm-input-textarea::placeholder {
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
}
.comm-input-textarea:focus {
outline: none;
box-shadow: none;
}
.comm-input-channel {
position: absolute;
left: 10px;
bottom: 8px;
}
.comm-input-actions {
position: absolute;
right: 10px;
bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.comm-mic-active {
border-color: rgba(255, 95, 95, 0.7) !important;
background: rgba(255, 95, 95, 0.12) !important;
color: rgba(185, 30, 30, 0.9) !important;
}
.comm-thread-surface {
background-color: #eaf3ff;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='132' height='132' viewBox='0 0 132 132'%3E%3Cg fill='none' stroke='%2395acd3' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round' opacity='0.22'%3E%3Cpath d='M16 20h14a6 6 0 0 1 0 12h-7l-5 4v-4h-2a6 6 0 0 1 0-12z'/%3E%3Ccircle cx='92' cy='28' r='6'/%3E%3Cpath d='M88 62h18a5 5 0 0 1 0 10H96l-4 3v-3h-4a5 5 0 0 1 0-10z'/%3E%3Cpath d='M24 86h8m-4-4v8'/%3E%3Cpath d='M74 96l2.3 4.8 5.3.8-3.8 3.7.9 5.2-4.7-2.4-4.7 2.4.9-5.2-3.8-3.7 5.3-.8z'/%3E%3C/g%3E%3C/svg%3E");
background-size: 132px 132px;
background-repeat: repeat;
}
.comm-thread-surface::after {
content: "";
display: block;
height: 14px;
}
.comm-pin-context-menu {
position: fixed;
z-index: 60;
min-width: 128px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
border-radius: 10px;
background: var(--color-base-100);
box-shadow: 0 16px 30px rgba(11, 23, 46, 0.22);
padding: 4px;
}
.comm-pin-context-menu-item {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
text-align: left;
padding: 7px 9px;
transition: background-color 120ms ease;
}
.comm-pin-context-menu-item:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-base-200) 82%, transparent);
}
.comm-pin-context-menu-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.comm-event-modal {
position: absolute;
inset: 0;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(14, 22, 38, 0.42);
backdrop-filter: blur(2px);
}
.comm-event-modal-card {
width: min(520px, 100%);
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
border-radius: 14px;
background: var(--color-base-100);
box-shadow: 0 24px 48px rgba(11, 23, 46, 0.25);
padding: 14px;
}
.feed-chart-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background:
radial-gradient(circle at 20% 20%, rgba(30, 107, 255, 0.12), transparent 45%),
radial-gradient(circle at 80% 80%, rgba(30, 107, 255, 0.08), transparent 45%),
#f6f9ff;
border-bottom: 1px solid rgba(30, 107, 255, 0.15);
}
.feed-chart-bars {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
max-width: 280px;
height: 100%;
}
.feed-chart-bars span {
flex: 1 1 0;
border-radius: 999px 999px 6px 6px;
background: linear-gradient(180deg, rgba(30, 107, 255, 0.9), rgba(30, 107, 255, 0.35));
}
.feed-chart-pie {
width: min(140px, 70%);
aspect-ratio: 1;
border-radius: 999px;
background: conic-gradient(
rgba(30, 107, 255, 0.92) 0 42%,
rgba(30, 107, 255, 0.55) 42% 73%,
rgba(30, 107, 255, 0.25) 73% 100%
);
box-shadow: 0 8px 24px rgba(30, 107, 255, 0.2);
}
.call-wave-card {
background: var(--color-base-100);
}
.comm-call-wave-wrap {
position: relative;
}
.comm-call-wave {
height: 30px;
width: 100%;
overflow: hidden;
}
.comm-call-wave :deep(wave) {
display: block;
height: 100% !important;
}
.comm-call-wave :deep(canvas) {
height: 100% !important;
}
.call-wave-center-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
width: 34px;
height: 34px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, transparent);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
background: color-mix(in oklab, var(--color-base-100) 72%, transparent);
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity 140ms ease, background-color 140ms ease;
}
.comm-call-wave-wrap:hover .call-wave-center-play,
.comm-call-wave-wrap:focus-within .call-wave-center-play {
opacity: 1;
pointer-events: auto;
}
.call-wave-center-play:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-base-100) 58%, var(--color-base-200));
}
.call-wave-center-play:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.call-transcript-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
background: color-mix(in oklab, var(--color-base-100) 90%, transparent);
}
.call-transcript-toggle:hover {
background: color-mix(in oklab, var(--color-base-100) 72%, var(--color-base-200));
}
.call-transcript-loader {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 28px;
}
.call-transcript-loader span {
display: block;
width: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 40%, transparent);
animation: transcript-ladder 1s ease-in-out infinite;
}
.call-transcript-loader span:nth-child(1) {
height: 8px;
animation-delay: 0ms;
}
.call-transcript-loader span:nth-child(2) {
height: 14px;
animation-delay: 120ms;
}
.call-transcript-loader span:nth-child(3) {
height: 20px;
animation-delay: 240ms;
}
.call-transcript-loader span:nth-child(4) {
height: 14px;
animation-delay: 360ms;
}
@keyframes transcript-ladder {
0%, 100% {
transform: scaleY(0.55);
opacity: 0.45;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
.accordion-enter-active,
.accordion-leave-active {
transition: all 160ms ease;
}
.accordion-enter-from,
.accordion-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.context-scope-block {
position: relative;
cursor: crosshair;
transition: box-shadow 220ms ease, outline-color 220ms ease, transform 220ms ease;
}
.context-scope-block-active {
outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent);
outline-offset: 2px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 30%, transparent) inset;
}
.context-scope-block-selected {
outline: 2px solid color-mix(in oklab, var(--color-primary) 72%, transparent);
outline-offset: 2px;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 22%, transparent) inset;
}
.context-scope-label {
position: absolute;
top: 6px;
left: 8px;
z-index: 20;
border-radius: 6px;
border: 1px solid color-mix(in oklab, var(--color-primary) 40%, transparent);
background: color-mix(in oklab, var(--color-base-100) 86%, var(--color-primary));
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: color-mix(in oklab, var(--color-primary-content) 65%, var(--color-base-content));
}
</style>