From 129daa31d7c91b03aa856af1beee768c0b09de2f Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:04:49 +0700 Subject: [PATCH] Add URL-based change review and selective change-set rollback --- frontend/app.vue | 486 +++++++++++++++--- .../graphql/operations/chat-messages.graphql | 3 + .../rollback-change-set-items.graphql | 5 + frontend/server/graphql/schema.ts | 91 +++- frontend/server/utils/changeSet.ts | 96 +++- 5 files changed, 593 insertions(+), 88 deletions(-) create mode 100644 frontend/graphql/operations/rollback-change-set-items.graphql diff --git a/frontend/app.vue b/frontend/app.vue index ad317cf..7e6364a 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -18,12 +18,13 @@ import archiveChatConversationMutation from "./graphql/operations/archive-chat-c import toggleContactPinMutation from "./graphql/operations/toggle-contact-pin.graphql?raw"; import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw"; import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw"; +import rollbackChangeSetItemsMutation from "./graphql/operations/rollback-change-set-items.graphql?raw"; 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 PeopleLeftMode = "contacts" | "calendar" | "changes"; type PeopleSortMode = "name" | "lastContact" | "company" | "country"; type FeedCard = { @@ -321,11 +322,14 @@ type PilotMessage = { 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; @@ -1189,8 +1193,13 @@ watchEffect(() => { livePilotAssistantText.value = textPart?.text ?? ""; }); -const changePanelOpen = ref(true); const changeActionBusy = ref(false); +const activeChangeSetId = ref(""); +const activeChangeStep = ref(0); +const changeSelectionByItemId = ref>({}); +const focusedCalendarEventId = ref(""); +const uiPathSyncLocked = ref(false); +let popstateHandler: (() => void) | null = null; const pilotHeaderPhrases = [ "Every step moves you forward", "Focus first, results follow", @@ -1211,6 +1220,185 @@ const latestChangeMessage = computed(() => { ); }); +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 activeChangeItem = computed(() => { + const items = activeChangeItems.value; + if (!items.length) return null; + const idx = Math.max(0, Math.min(activeChangeStep.value, items.length - 1)); + return items[idx] ?? null; +}); + +const selectedRollbackItemIds = computed(() => + activeChangeItems.value + .filter((item) => !item.rolledBack && changeSelectionByItemId.value[item.id]) + .map((item) => item.id), +); +const selectedRollbackCount = computed(() => selectedRollbackItemIds.value.length); + +function toggleAllChangeItems(checked: boolean) { + const next: Record = {}; + for (const item of activeChangeItems.value) { + next[item.id] = item.rolledBack ? false : checked; + } + changeSelectionByItemId.value = next; +} + +function calendarCursorToken(date: Date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +} + +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 normalizedConversationId() { + return (selectedChatId.value || authMe.value?.conversation.id || "pilot").trim(); +} + +function currentUiPath() { + if (selectedTab.value !== "communications") { + return `/chat/${encodeURIComponent(normalizedConversationId())}`; + } + + if (peopleLeftMode.value === "changes" && activeChangeSetId.value.trim()) { + const step = Math.max(1, activeChangeStep.value + 1); + return `/changes/${encodeURIComponent(activeChangeSetId.value.trim())}/step/${step}`; + } + + if (peopleLeftMode.value === "calendar") { + if (focusedCalendarEventId.value.trim()) { + return `/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`; + } + return `/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarCursorToken(calendarCursor.value))}`; + } + + return `/chat/${encodeURIComponent(normalizedConversationId())}`; +} + +function syncPathFromUi(push = false) { + if (process.server) return; + const nextPath = currentUiPath(); + const currentPath = window.location.pathname; + if (nextPath === currentPath) return; + if (push) { + window.history.pushState({}, "", nextPath); + } else { + window.history.replaceState({}, "", nextPath); + } +} + +function ensureChangeSelectionSeeded(message: PilotMessage | null | undefined) { + if (!message?.changeItems?.length) { + changeSelectionByItemId.value = {}; + return; + } + const next: Record = {}; + for (const item of message.changeItems) { + const prev = changeSelectionByItemId.value[item.id]; + next[item.id] = typeof prev === "boolean" ? prev : !item.rolledBack; + } + changeSelectionByItemId.value = next; +} + +function setPeopleLeftMode(mode: PeopleLeftMode, push = false) { + selectedTab.value = "communications"; + peopleLeftMode.value = mode; + if (mode !== "changes") { + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + } + focusedCalendarEventId.value = ""; + syncPathFromUi(push); +} + +function openChangeReview(changeSetId: string, step = 0, push = true) { + const targetId = String(changeSetId ?? "").trim(); + if (!targetId) return; + selectedTab.value = "communications"; + peopleLeftMode.value = "changes"; + activeChangeSetId.value = targetId; + const items = activeChangeMessage.value?.changeItems ?? []; + activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0; + ensureChangeSelectionSeeded(activeChangeMessage.value); + syncPathFromUi(push); +} + +function applyPathToUi(pathname: string) { + const path = String(pathname || "/").trim() || "/"; + 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; + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + 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 cursor = parseCalendarCursorToken(rawCursor); + selectedTab.value = "communications"; + peopleLeftMode.value = "calendar"; + focusedCalendarEventId.value = ""; + calendarView.value = view; + if (cursor) { + calendarCursor.value = cursor; + selectedDateKey.value = dayKey(cursor); + } + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + return; + } + + const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i); + if (changesMatch) { + const rawId = decodeURIComponent(changesMatch[1] ?? "").trim(); + const rawStep = Number(changesMatch[2] ?? "1"); + selectedTab.value = "communications"; + peopleLeftMode.value = "changes"; + focusedCalendarEventId.value = ""; + activeChangeSetId.value = rawId; + activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0; + ensureChangeSelectionSeeded(activeChangeMessage.value); + return; + } + + selectedTab.value = "communications"; + peopleLeftMode.value = "contacts"; + focusedCalendarEventId.value = ""; + activeChangeSetId.value = ""; + activeChangeStep.value = 0; +} + async function confirmLatestChangeSet() { if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return; changeActionBusy.value = true; @@ -1227,12 +1415,77 @@ async function rollbackLatestChangeSet() { changeActionBusy.value = true; try { await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation); - await Promise.all([loadPilotMessages(), refreshCrmData()]); + await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]); + if (peopleLeftMode.value === "changes") { + 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 = selectedRollbackItemIds.value; + if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return; + + changeActionBusy.value = true; + try { + await gqlFetch<{ rollbackChangeSetItems: { ok: boolean } }>(rollbackChangeSetItemsMutation, { + changeSetId: targetChangeSetId, + itemIds, + }); + await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]); + ensureChangeSelectionSeeded(activeChangeMessage.value); + } 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)); + syncPathFromUi(true); +} + +function openChangeItemTarget(item: NonNullable[number]) { + if (!item) return; + + if (item.entity === "calendar_event" && item.entityId) { + const event = sortedEvents.value.find((x) => x.id === item.entityId); + setPeopleLeftMode("calendar", true); + if (event) { + pickDate(event.start.slice(0, 10)); + } + focusedCalendarEventId.value = item.entityId; + syncPathFromUi(true); + return; + } + + if (item.entity === "contact_note" && item.entityId) { + const contact = contacts.value.find((x) => x.id === item.entityId); + setPeopleLeftMode("contacts", true); + if (contact) selectedContactId.value = contact.id; + syncPathFromUi(true); + return; + } + + setPeopleLeftMode("contacts", true); +} + +watch( + () => activeChangeMessage.value?.changeSetId, + () => { + if (peopleLeftMode.value !== "changes") return; + ensureChangeSelectionSeeded(activeChangeMessage.value); + const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1); + if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex; + }, +); + if (process.server) { await bootstrapSession(); } @@ -1247,6 +1500,23 @@ onMounted(() => { lifecycleNowMs.value = Date.now(); }, 15000); + uiPathSyncLocked.value = true; + try { + applyPathToUi(window.location.pathname); + } finally { + uiPathSyncLocked.value = false; + } + syncPathFromUi(false); + popstateHandler = () => { + uiPathSyncLocked.value = true; + try { + applyPathToUi(window.location.pathname); + } finally { + uiPathSyncLocked.value = false; + } + }; + window.addEventListener("popstate", popstateHandler); + if (!authResolved.value) { void bootstrapSession().finally(() => { if (authMe.value) startPilotBackgroundPolling(); @@ -1274,6 +1544,10 @@ onBeforeUnmount(() => { pilotRecorderStream = null; } stopPilotBackgroundPolling(); + if (popstateHandler) { + window.removeEventListener("popstate", popstateHandler); + popstateHandler = null; + } if (lifecycleClock) { clearInterval(lifecycleClock); lifecycleClock = null; @@ -1401,6 +1675,7 @@ const yearMonths = computed(() => { 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); @@ -1427,24 +1702,35 @@ function shiftCalendar(step: number) { } 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 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"; } +watch( + () => [selectedTab.value, peopleLeftMode.value, selectedChatId.value, calendarView.value, calendarCursorToken(calendarCursor.value), activeChangeSetId.value, activeChangeStep.value], + () => { + if (process.server || uiPathSyncLocked.value) return; + syncPathFromUi(false); + }, +); + const contactSearch = ref(""); const selectedCountry = ref("All"); const selectedLocation = ref("All"); @@ -2325,8 +2611,7 @@ function pushPilotNote(text: string) { } function openCommunicationThread(contact: string) { - selectedTab.value = "communications"; - peopleLeftMode.value = "contacts"; + setPeopleLeftMode("contacts", true); selectedDealStepsExpanded.value = false; const linkedContact = contacts.value.find((item) => item.name === contact); if (linkedContact) { @@ -2350,18 +2635,20 @@ function openDealThread(deal: Deal) { function openThreadFromCalendarItem(event: CalendarEvent) { if (!event.contact?.trim()) { - selectedTab.value = "communications"; - peopleLeftMode.value = "calendar"; + setPeopleLeftMode("calendar", true); pickDate(event.start.slice(0, 10)); + focusedCalendarEventId.value = event.id; + syncPathFromUi(true); return; } openCommunicationThread(event.contact); } function openEventFromContact(event: CalendarEvent) { - selectedTab.value = "communications"; - peopleLeftMode.value = "calendar"; + setPeopleLeftMode("calendar", true); pickDate(event.start.slice(0, 10)); + focusedCalendarEventId.value = event.id; + syncPathFromUi(true); } function openMessageFromContact(channel: CommItem["channel"]) { @@ -2537,8 +2824,7 @@ async function executeFeedAction(card: FeedCard) { selectedDateKey.value = dayKey(start); calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1); - selectedTab.value = "communications"; - peopleLeftMode.value = "calendar"; + setPeopleLeftMode("calendar", true); return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`; } @@ -2762,6 +3048,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ row.entity }}: {{ row.count }} +
+ + + status: {{ message.changeStatus || "pending" }} + +
@@ -2824,52 +3122,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
-
-
-
-

Detected changes

-

- {{ latestChangeMessage.changeSummary || `Changed: ${latestChangeMessage.changeItems.length}` }} - · status: {{ latestChangeMessage.changeStatus || "pending" }} -

-
-
- - - -
-
-
-
-

{{ item.title }}

-

{{ item.entity }} · {{ item.action }}

-

- {{ item.before }}

-

+ {{ item.after }}

-
-
-
-