diff --git a/site/src/App.vue b/site/src/App.vue index d5eaeca..8418366 100644 --- a/site/src/App.vue +++ b/site/src/App.vue @@ -40,6 +40,11 @@ const visibleNavItems = computed(() => navItems.filter((item) => (item.manager ? userStore.user?.is_manager : true)), ) +const organizationCount = computed(() => userStore.userJoinedOrganizations?.length || 0) +const singleOrganization = computed(() => + organizationCount.value === 1 ? userStore.userJoinedOrganizations[0] : null, +) + const selectedKeys = computed(() => { for (const item of visibleNavItems.value) { if (item.key === '/' && route.path === '/') return [item.key] @@ -85,6 +90,14 @@ const handleLogout = async () => { router.push('/') } +const handleOrganizationChange = (value: string) => { + const organization = userStore.userJoinedOrganizations.find((item) => item.uuid === value) || null + userStore.setSelectedOrganization(organization) + if (organization) { + router.push(`/organization/${organization.uuid}`) + } +} + onMounted(() => { userStore.fetchSession() }) @@ -99,7 +112,7 @@ const user = userStore
+ + {{ singleOrganization.name }} + + {{ user.displayName || 'Account' }} @@ -205,17 +220,18 @@ const user = userStore diff --git a/site/src/views/AboutView.vue b/site/src/views/AboutView.vue index c46e8ad..330b9fc 100644 --- a/site/src/views/AboutView.vue +++ b/site/src/views/AboutView.vue @@ -93,7 +93,8 @@ const features = [ gap: 1rem; } .feature-card { - background: var(--ant-card-background, #08121a); + background: #ffffff; + border: 1px solid #dbe3ec; border-radius: 6px; overflow: hidden; display: flex; @@ -107,4 +108,8 @@ const features = [ .feature-body { padding: 0.75rem 1rem; } + +.feature-body :deep(.ant-typography-secondary) { + color: #4b5563 !important; +} diff --git a/site/src/views/AgentDetailView.vue b/site/src/views/AgentDetailView.vue index 73bd618..2f8b4cb 100644 --- a/site/src/views/AgentDetailView.vue +++ b/site/src/views/AgentDetailView.vue @@ -346,9 +346,9 @@ onUnmounted(() => { } .panel { - background: #0f172a; - border: 1px solid #1f2937; - color: #e5e7eb; + background: #ffffff; + border: 1px solid #dbe3ec; + color: #1f2937; } .header { @@ -369,26 +369,26 @@ onUnmounted(() => { gap: 0.5rem; margin: 1rem 0; padding: 0.5rem; - background: #1f2937; + background: #f8fafc; border-radius: 4px; } .execution-controls { - background: #1f2937; + background: #f8fafc; padding: 1rem; border-radius: 4px; margin: 1rem 0; } .log-container { - background: #1f2937; + background: #f8fafc; border-radius: 4px; max-height: 500px; overflow-y: auto; } .log-item { - border-bottom: 1px solid #374151 !important; + border-bottom: 1px solid #dbe3ec !important; padding: 0.75rem !important; } @@ -405,16 +405,16 @@ onUnmounted(() => { .log-time { font-size: 0.75rem; - color: #9ca3af; + color: #6b7280; } .log-message { - color: #e5e7eb; + color: #1f2937; font-size: 0.9rem; } .log-content { - background: #111827; + background: #ffffff; padding: 0.5rem; border-radius: 3px; overflow-x: auto; @@ -423,7 +423,7 @@ onUnmounted(() => { .log-content pre { margin: 0; font-size: 0.8rem; - color: #d1d5db; + color: #4b5563; } .response-section { @@ -431,17 +431,17 @@ onUnmounted(() => { } .response-card { - background: #1f2937; - border: 1px solid #374151; + background: #ffffff; + border: 1px solid #dbe3ec; } .response-final { - border-color: #6366f1; - box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35); + border-color: #2563eb; + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25); } .response-content { - color: #e5e7eb; + color: #1f2937; font-size: 1rem; line-height: 1.6; white-space: pre-wrap; @@ -452,7 +452,7 @@ onUnmounted(() => { .markdown-body :deep(h1), .markdown-body :deep(h2), .markdown-body :deep(h3) { - color: #f8fafc; + color: #1f2937; margin-top: 1rem; margin-bottom: 0.5rem; } @@ -461,15 +461,15 @@ onUnmounted(() => { .markdown-body :deep(ol) { padding-left: 1.5rem; margin-bottom: 1rem; - color: #e5e7eb; + color: #1f2937; } .markdown-body :deep(code) { - background: #020617; + background: #eff6ff; padding: 0.2rem 0.4rem; border-radius: 4px; font-family: monospace; - color: #10b981; + color: #1d4ed8; } .markdown-body :deep(p) { diff --git a/site/src/views/AgentsView.vue b/site/src/views/AgentsView.vue index e077740..082d6ad 100644 --- a/site/src/views/AgentsView.vue +++ b/site/src/views/AgentsView.vue @@ -147,8 +147,8 @@ onMounted(() => { padding: 2rem 1rem; } .panel { - background: #0f172a; - border: 1px solid #1f2937; + background: #ffffff; + border: 1px solid #dbe3ec; } .filters { display: flex; @@ -157,11 +157,11 @@ onMounted(() => { flex-wrap: wrap; } .item :deep(.ant-list-item-meta-title) { - color: #f8fafc; + color: #1f2937; font-weight: 600; } .config-summary { - color: #94a3b8; + color: #6b7280; font-size: 0.85rem; } .empty { diff --git a/site/src/views/HomeView.vue b/site/src/views/HomeView.vue index 04860cd..d592617 100644 --- a/site/src/views/HomeView.vue +++ b/site/src/views/HomeView.vue @@ -78,10 +78,10 @@ const testimonials = [ ] const logos = [ - 'https://dummyimage.com/120x40/111827/ffffff&text=Nova', - 'https://dummyimage.com/120x40/1f2937/ffffff&text=Helio', - 'https://dummyimage.com/120x40/111827/ffffff&text=Arcus', - 'https://dummyimage.com/120x40/1f2937/ffffff&text=Vertex', + 'https://dummyimage.com/120x40/f8fafc/1f2937&text=Nova', + 'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Helio', + 'https://dummyimage.com/120x40/f8fafc/1f2937&text=Arcus', + 'https://dummyimage.com/120x40/f1f5f9/1f2937&text=Vertex', ] @@ -102,7 +102,7 @@ const logos = [ - + @@ -136,7 +136,7 @@ const logos = [ - + {{ feature.title }} {{ feature.description }} Live @@ -198,7 +198,7 @@ const logos = [ } .hero-sub { font-size: 1.05rem; - color: #cbd5e1; + color: #6b7280; } .hero-card { border: none; @@ -211,12 +211,12 @@ const logos = [ } .hero-overlay { margin-top: 0.75rem; - color: #8b5cf6; + color: #2563eb; font-weight: 600; } .stat-card { - background: #0f172a; - border: 1px solid #1f2937; + background: #ffffff; + border: 1px solid #dbe3ec; } .trusted { text-align: center; @@ -239,17 +239,17 @@ const logos = [ } .feature-card { height: 100%; - background: #0f172a; - border: 1px solid #1f2937; - color: #e5e7eb; + background: #ffffff; + border: 1px solid #dbe3ec; + color: #1f2937; } .journeys { margin: 2.5rem 0; } .journey-card { - background: #0f172a; - border: 1px solid #1f2937; - color: #e5e7eb; + background: #ffffff; + border: 1px solid #dbe3ec; + color: #1f2937; } .testimonials { margin: 2.5rem 0; @@ -258,9 +258,9 @@ const logos = [ padding: 0 6px; } .testimonial-card { - background: #0f172a; - border: 1px solid #1f2937; - color: #e5e7eb; + background: #ffffff; + border: 1px solid #dbe3ec; + color: #1f2937; } @media (max-width: 768px) { .page { diff --git a/site/src/views/OnboardingView.vue b/site/src/views/OnboardingView.vue index 2a70203..7d8f6a0 100644 --- a/site/src/views/OnboardingView.vue +++ b/site/src/views/OnboardingView.vue @@ -17,13 +17,12 @@ import { Tag, Popconfirm, } from 'ant-design-vue' -import { apiClient, API } from '../router/api' -import { useAgentStore } from '../stores/agentStore' +import { apiClient, API, isAxiosError } from '../router/api' +import { useOnboardingAgentStore } from '../stores/onboardingAgentStore' import type { OnboardingFlow, OnboardingPage, OnboardingSession, - OnboardingSessionSummary, OnboardingFlowSummary, } from '../types/onboarding' @@ -33,7 +32,7 @@ import DOMPurify from 'dompurify' const marked = new Marked() const route = useRoute() const router = useRouter() -const agentStore = useAgentStore() +const agentStore = useOnboardingAgentStore() const roleId = computed(() => route.params.roleId as string) const flowDetails = ref(null) @@ -43,6 +42,17 @@ const loading = ref(false) const isAutoGenerating = ref(false) const generationHandled = ref(false) const deletingFlow = ref(false) +const visitedPageUuids = ref([]) +const quizResult = ref<{ + score_percentage: number + pass_mark: number + correct_count: number + gradable_count: number + missing_required_keys?: string[] +} | null>(null) +const kaQuestion = ref('') +const kaLoading = ref(false) +const kaMode = ref<'separate' | 'update_page'>('separate') // eslint-disable-next-line @typescript-eslint/no-explicit-any const formState = reactive>({}) @@ -54,30 +64,95 @@ const hasNext = computed(() => currentPageIndex.value < pages.value.length - 1) const hasPrev = computed(() => currentPageIndex.value > 0) const isError = computed(() => agentStore.executionStatus === 'failed') +const completedModules = computed(() => { + const state = (session.value as unknown as { state?: Record } | null)?.state + const raw = state?.completed_modules + return Array.isArray(raw) ? raw.map((item) => String(item)) : [] +}) + +const pageHelpByPage = computed>>(() => { + const state = (session.value as unknown as { state?: Record } | null)?.state + const raw = state?.page_help + return raw && typeof raw === 'object' + ? (raw as Record>) + : {} +}) + +const currentPageHelp = computed(() => { + if (!currentPage.value) return [] + return pageHelpByPage.value[currentPage.value.uuid] || [] +}) + const renderedBody = computed(() => { if (!currentPage.value?.body) return '' return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string) }) -const getSessionRoleUuid = (sessionData: OnboardingSessionSummary): string | undefined => { - if (typeof sessionData.role === 'string') return sessionData.role - return sessionData.role?.uuid -} - const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => { if (typeof flowData.role === 'string') return flowData.role return flowData.role?.uuid } -const findCompletedSessionForRole = async (): Promise => { - const sessionRes = await apiClient.get(API.onboarding.sessions.list(), { - params: { 'role__uuid': roleId.value }, - }) - return ( - sessionRes.data.find( - (item) => item.status === 'completed' && getSessionRoleUuid(item) === roleId.value, - ) || null +const getPageIndexByUuid = (pageUuid?: string | null): number => { + if (!pageUuid) return -1 + return pages.value.findIndex((page) => String(page.uuid) === String(pageUuid)) +} + +const restorePageProgressFromSession = () => { + const sessionState = (session.value as unknown as { state?: Record } | null)?.state + + const visitedRaw = sessionState?.visited_pages + visitedPageUuids.value = Array.isArray(visitedRaw) + ? visitedRaw.map((uuid) => String(uuid)).filter((uuid) => getPageIndexByUuid(uuid) >= 0) + : [] + + const lastPageIndex = getPageIndexByUuid( + typeof sessionState?.last_page_uuid === 'string' ? sessionState.last_page_uuid : undefined, ) + + const visitedMaxIndex = Array.isArray(visitedRaw) + ? visitedRaw.reduce((maxIndex, pageUuid) => { + const pageIndex = getPageIndexByUuid(String(pageUuid)) + return pageIndex > maxIndex ? pageIndex : maxIndex + }, -1) + : -1 + + const completedModulesRaw = sessionState?.completed_modules + const completedMaxIndex = Array.isArray(completedModulesRaw) + ? completedModulesRaw.reduce((maxIndex, pageUuid) => { + const pageIndex = getPageIndexByUuid(String(pageUuid)) + return pageIndex > maxIndex ? pageIndex : maxIndex + }, -1) + : -1 + + const inferredInProgressIndex = + completedMaxIndex >= 0 && completedMaxIndex < pages.value.length - 1 + ? completedMaxIndex + 1 + : completedMaxIndex + + const responsesRaw = sessionState?.responses + const responseMaxIndex = responsesRaw && typeof responsesRaw === 'object' + ? Object.keys(responsesRaw as Record).reduce((maxIndex, pageUuid) => { + const pageIndex = getPageIndexByUuid(String(pageUuid)) + return pageIndex > maxIndex ? pageIndex : maxIndex + }, -1) + : -1 + + const resumeIndex = Math.max( + 0, + lastPageIndex, + visitedMaxIndex, + responseMaxIndex, + completedMaxIndex, + inferredInProgressIndex, + ) + + if (pages.value.length === 0) { + currentPageIndex.value = 0 + return + } + + currentPageIndex.value = Math.min(resumeIndex, pages.value.length - 1) } const retryGeneration = async () => { @@ -86,7 +161,7 @@ const retryGeneration = async () => { try { const response = await apiClient.get(API.onboarding.flows.list(), { - params: { 'role__uuid': roleId.value }, + params: { role_uuid: roleId.value }, }) if (response.data && response.data.length > 0) { @@ -116,6 +191,8 @@ const resetCurrentFlow = async () => { flowDetails.value = null session.value = null currentPageIndex.value = 0 + visitedPageUuids.value = [] + quizResult.value = null Object.keys(formState).forEach((k) => delete formState[k]) generationHandled.value = false @@ -137,7 +214,7 @@ const initOnboarding = async () => { loading.value = true try { const response = await apiClient.get(API.onboarding.flows.list(), { - params: { 'role__uuid': roleId.value }, + params: { role_uuid: roleId.value }, }) if (response.data && response.data.length > 0) { @@ -145,19 +222,12 @@ const initOnboarding = async () => { if (!matchingFlow) { flowDetails.value = null session.value = null + visitedPageUuids.value = [] return } flowDetails.value = matchingFlow - const completedSession = await findCompletedSessionForRole() - if (completedSession) { - session.value = completedSession as unknown as OnboardingSession - currentPageIndex.value = 0 - Object.keys(formState).forEach((k) => delete formState[k]) - return - } - await loadFlow(matchingFlow.uuid) } else { if (!generationHandled.value) { @@ -212,6 +282,8 @@ watch( flowDetails.value = null session.value = null currentPageIndex.value = 0 + visitedPageUuids.value = [] + quizResult.value = null generationHandled.value = false isAutoGenerating.value = false Object.keys(formState).forEach((k) => delete formState[k]) @@ -234,41 +306,243 @@ const loadFlow = async (flowUuid: string) => { return } + restorePageProgressFromSession() + syncVisitedPages() hydrateFormState() + await persistCurrentPageVisit() } const hydrateFormState = () => { if (!currentPage.value) return + + const sessionState = (session.value as unknown as { state?: Record } | null)?.state + const storedResponsesRaw = sessionState?.responses + const pageStoredResponses = + storedResponsesRaw && + typeof storedResponsesRaw === 'object' && + currentPage.value.uuid in (storedResponsesRaw as Record) + ? ((storedResponsesRaw as Record)[ + currentPage.value.uuid + ] as Record) + : {} + Object.keys(formState).forEach((k) => delete formState[k]) currentPage.value.fields?.forEach((f) => { + if (pageStoredResponses && f.key in pageStoredResponses) { + formState[f.key] = pageStoredResponses[f.key] + return + } formState[f.key] = f.default_value ?? '' }) } +const buildCurrentResponses = () => { + const response: Record = {} + currentPage.value?.fields?.forEach((field) => { + response[field.key] = formState[field.key] + }) + return response +} + +const syncVisitedPages = () => { + const nextVisited = new Set(visitedPageUuids.value) + + const sessionState = (session.value as unknown as { state?: Record } | null)?.state + const visitedRaw = sessionState?.visited_pages + if (Array.isArray(visitedRaw)) { + visitedRaw.forEach((pageUuid) => { + nextVisited.add(String(pageUuid)) + }) + } + + completedModules.value.forEach((pageUuid) => { + nextVisited.add(String(pageUuid)) + }) + + const storedResponsesRaw = sessionState?.responses + if (storedResponsesRaw && typeof storedResponsesRaw === 'object') { + Object.keys(storedResponsesRaw as Record).forEach((pageUuid) => { + nextVisited.add(String(pageUuid)) + }) + } + + const currentUuid = currentPage.value?.uuid + if (currentUuid) { + nextVisited.add(String(currentUuid)) + } + + visitedPageUuids.value = Array.from(nextVisited) +} + +const persistCurrentPageVisit = async () => { + if (!session.value || !currentPage.value || session.value.status === 'completed') return + + try { + const response = await apiClient.post<{ + status: string + session_state?: Record + }>(API.onboarding.sessions.interact(session.value.uuid), { + page_uuid: currentPage.value.uuid, + }) + + const apiSessionState = response.data?.session_state + if (apiSessionState && session.value) { + ;(session.value as unknown as { state?: Record }).state = apiSessionState + syncVisitedPages() + } + } catch { + // Avoid noisy errors for background navigation sync attempts. + } +} + +const getPageStatus = (page: OnboardingPage, index: number) => { + if (completedModules.value.includes(page.uuid)) return 'Completed' + if (index === currentPageIndex.value) return 'In Progress' + if (visitedPageUuids.value.includes(page.uuid)) return 'In Progress' + return 'Not Started' +} + +const getPageStatusColor = (page: OnboardingPage, index: number) => { + const status = getPageStatus(page, index) + if (status === 'Completed') return 'green' + if (status === 'In Progress') return 'blue' + return 'default' +} + +const canNavigateToPage = (targetIndex: number) => { + if (targetIndex <= currentPageIndex.value) return true + for (let index = 0; index < targetIndex; index += 1) { + const previousPage = pages.value[index] + if (!previousPage) return false + if (!completedModules.value.includes(previousPage.uuid)) return false + } + return true +} + +const jumpToPage = (index: number) => { + if (!canNavigateToPage(index)) { + message.warning('Complete required attempts on earlier modules first.') + return + } + currentPageIndex.value = index + window.scrollTo(0, 0) +} + +const goBackPage = () => { + if (!hasPrev.value) return + currentPageIndex.value -= 1 + window.scrollTo(0, 0) +} + const onSubmitPage = async () => { if (!currentPage.value || !session.value) return + try { - await apiClient.post(API.onboarding.sessions.interact(session.value.uuid), { + const response = await apiClient.post<{ + status: string + session_state?: Record + }>(API.onboarding.sessions.interact(session.value.uuid), { page_uuid: currentPage.value.uuid, - responses: formState, + responses: buildCurrentResponses(), }) + const apiSessionState = response.data?.session_state + if (apiSessionState && session.value) { + ;(session.value as unknown as { state?: Record }).state = apiSessionState + } + + syncVisitedPages() + + quizResult.value = null + if (hasNext.value) { currentPageIndex.value++ - hydrateFormState() window.scrollTo(0, 0) - } else { - await apiClient.post(API.onboarding.sessions.complete(session.value.uuid)) - message.success('Onboarding Finished!') - router.push('/organization') + return + } + + const completeResponse = await apiClient.post<{ + message: string + quiz_result?: { + score_percentage: number + pass_mark: number + correct_count: number + gradable_count: number + missing_required_keys?: string[] + } + }>(API.onboarding.sessions.complete(session.value.uuid)) + + if (completeResponse.data?.quiz_result) { + quizResult.value = completeResponse.data.quiz_result + } + + message.success('Onboarding Finished!') + router.push('/organization') + } catch (error: unknown) { + if (isAxiosError<{ error?: string; quiz_result?: typeof quizResult.value }>(error)) { + const data = error.response?.data + if (data?.quiz_result) { + quizResult.value = data.quiz_result + message.error( + `${data.error || 'Final quiz not passed.'} Score: ${data.quiz_result.score_percentage}% (required ${data.quiz_result.pass_mark}%).`, + ) + return + } } - } catch { message.error('Failed to save progress') } } +const askKnowledgeAgent = async () => { + if (!session.value || !currentPage.value || !kaQuestion.value.trim()) return + + kaLoading.value = true + try { + const response = await apiClient.post<{ + status: string + answer: string + updated_page: boolean + session_state?: Record + }>(API.onboarding.sessions.askKa(session.value.uuid), { + page_uuid: currentPage.value.uuid, + message: kaQuestion.value, + mode: kaMode.value, + }) + + const apiSessionState = response.data?.session_state + if (apiSessionState && session.value) { + ;(session.value as unknown as { state?: Record }).state = apiSessionState + } + + syncVisitedPages() + + if (response.data?.updated_page && flowDetails.value) { + const flowResponse = await apiClient.get( + API.onboarding.flows.byId(flowDetails.value.uuid), + ) + flowDetails.value = flowResponse.data + } + + kaQuestion.value = '' + } catch { + message.error('Could not retrieve clarification right now') + } finally { + kaLoading.value = false + } +} + onMounted(() => initOnboarding()) onUnmounted(() => agentStore.disconnect()) + +watch( + () => currentPageIndex.value, + async () => { + kaQuestion.value = '' + syncVisitedPages() + hydrateFormState() + await persistCurrentPageVisit() + }, +) diff --git a/site/src/views/ProgressDetailView.vue b/site/src/views/ProgressDetailView.vue index 0ee266b..64ddf4f 100644 --- a/site/src/views/ProgressDetailView.vue +++ b/site/src/views/ProgressDetailView.vue @@ -86,7 +86,7 @@ const loadData = async () => { loading.value = true try { const [rolesRes, sessionsRes] = await Promise.all([ - apiClient.get(API.organization.roles.mine()), + apiClient.get(API.roles.mine()), apiClient.get(API.onboarding.sessions.list()), ]) @@ -196,8 +196,8 @@ onMounted(() => { padding: 2rem 1rem; } .panel { - background: #0f172a; - border: 1px solid #1f2937; + background: #ffffff; + border: 1px solid #dbe3ec; } .head-row { display: flex; @@ -213,6 +213,6 @@ onMounted(() => { } .feedback { white-space: pre-wrap; - color: #cbd5e1; + color: #6b7280; } diff --git a/site/src/views/ProgressView.vue b/site/src/views/ProgressView.vue index d54fcfd..d497d8e 100644 --- a/site/src/views/ProgressView.vue +++ b/site/src/views/ProgressView.vue @@ -64,7 +64,7 @@ const loadProgress = async () => { loading.value = true try { const [rolesRes, sessionsRes, flowsRes] = await Promise.all([ - apiClient.get(API.organization.roles.mine()), + apiClient.get(API.roles.mine()), apiClient.get(API.onboarding.sessions.list()), apiClient.get(API.onboarding.flows.list()), ]) @@ -177,8 +177,8 @@ onMounted(() => { padding: 2rem 1rem; } .panel { - background: #0f172a; - border: 1px solid #1f2937; + background: #ffffff; + border: 1px solid #dbe3ec; } .row-meta { display: flex; @@ -191,7 +191,7 @@ onMounted(() => { } .feedback-text { margin-top: 0.4rem; - color: #cbd5e1; + color: #6b7280; white-space: pre-wrap; }