Added onboarding session progress view logic

This commit is contained in:
Viswamedha Nalabotu 2026-03-10 19:40:02 +00:00
parent a4f0fb3ea6
commit 83d6f38a24
6 changed files with 241 additions and 160 deletions

View file

@ -54,13 +54,22 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
await self.run_full_onboarding_generation(role_uuid) await self.run_full_onboarding_generation(role_uuid)
elif action == "progress_monitor": elif action == "progress_monitor":
role_uuid = data.get("role_uuid") or self.context_uuid role_uuid = data.get("role_uuid") or self.context_uuid
target_user_uuid = data.get("user_uuid")
flow_uuid = data.get("flow_uuid")
if not role_uuid: if not role_uuid:
await self.send_log("error", "Missing role_uuid for progress monitoring") await self.send_log("error", "Missing role_uuid for progress monitoring")
return return
if not await self.can_access_role(role_uuid, self.user.id): if not await self.can_access_role(role_uuid, self.user.id):
await self.send_log("error", "Forbidden") await self.send_log("error", "Forbidden")
return return
await self.run_progress_monitor(role_uuid) target_user_id = self.user.id
if target_user_uuid and str(target_user_uuid) != str(self.user.uuid):
target_user_id = await self.resolve_target_user_id(role_uuid, self.user.id, target_user_uuid)
if not target_user_id:
await self.send_log("error", "Forbidden")
return
await self.run_progress_monitor(role_uuid, target_user_id=target_user_id, flow_uuid=flow_uuid)
else: else:
user_message = data.get("query") or data.get("message") user_message = data.get("query") or data.get("message")
@ -232,7 +241,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"message": "Onboarding pipeline complete and structure saved." "message": "Onboarding pipeline complete and structure saved."
})) }))
async def run_progress_monitor(self, role_uuid): async def run_progress_monitor(self, role_uuid, target_user_id=None, flow_uuid=None):
await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor") await self.send_log("status", "Progress Monitor is analyzing your onboarding progress...", "monitor")
monitor_config = await self.get_config_by_type(role_uuid, 'monitor') monitor_config = await self.get_config_by_type(role_uuid, 'monitor')
@ -240,7 +249,11 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
await self.send_log("error", "Missing Progress Monitor AgentConfig for this role") await self.send_log("error", "Missing Progress Monitor AgentConfig for this role")
return return
progress_context = await self.get_role_progress_context(role_uuid, self.user.id) progress_context = await self.get_role_progress_context(
role_uuid,
target_user_id or self.user.id,
flow_uuid=flow_uuid,
)
monitor_prompt = ( monitor_prompt = (
"You are a progress monitoring agent for onboarding. " "You are a progress monitoring agent for onboarding. "
@ -250,12 +263,21 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
f"Progress context JSON:\n{json.dumps(progress_context)}" f"Progress context JSON:\n{json.dumps(progress_context)}"
) )
feedback = await self.orchestrate_ai( try:
monitor_prompt, feedback = await self.orchestrate_ai(
monitor_config, monitor_prompt,
min_internal_turns=1, monitor_config,
max_tokens=640, min_internal_turns=1,
) max_tokens=640,
raise_on_error=True,
)
except Exception as exc:
await self.send_log("error", f"Inference failed: {str(exc)}")
return
if str(feedback).startswith("Error:"):
await self.send_log("error", str(feedback))
return
await self.send(json.dumps({ await self.send(json.dumps({
"type": "completed", "type": "completed",
@ -265,6 +287,9 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"role_uuid": role_uuid, "role_uuid": role_uuid,
"feedback": feedback, "feedback": feedback,
"status": progress_context.get("latest_status", "unknown"), "status": progress_context.get("latest_status", "unknown"),
"user_id": target_user_id or self.user.id,
"flow_uuid": flow_uuid,
"is_completed": progress_context.get("is_completed", False),
} }
})) }))
@ -275,6 +300,7 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
min_internal_turns=2, min_internal_turns=2,
max_turns=6, max_turns=6,
max_tokens=None, max_tokens=None,
raise_on_error=False,
): ):
""" """
Handles the multi-turn ReAct loop (Reasoning + Tool Use). Handles the multi-turn ReAct loop (Reasoning + Tool Use).
@ -360,6 +386,8 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
except Exception as e: except Exception as e:
await self.send_log("error", f"Inference failed: {str(e)}") await self.send_log("error", f"Inference failed: {str(e)}")
if raise_on_error:
raise
return f"Error: {str(e)}" return f"Error: {str(e)}"
return last_content return last_content
@ -663,13 +691,20 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
).order_by('-updated_at').first() ).order_by('-updated_at').first()
@database_sync_to_async @database_sync_to_async
def get_role_progress_context(self, role_uuid, user_id): def get_role_progress_context(self, role_uuid, user_id, flow_uuid=None):
from apps.accounts.models import Role from apps.accounts.models import Role
role = Role.objects.get(uuid=role_uuid) role = Role.objects.get(uuid=role_uuid)
sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at')
latest_session = sessions.first()
active_flow = OnboardingFlow.objects.filter(role=role, is_active=True).order_by('-updated_at').first() active_flow = OnboardingFlow.objects.filter(role=role, is_active=True).order_by('-updated_at').first()
scoped_flow = None
if flow_uuid:
scoped_flow = OnboardingFlow.objects.filter(role=role, uuid=flow_uuid).first()
sessions = OnboardingSession.objects.filter(user_id=user_id, role=role).order_by('-updated_at')
if flow_uuid:
sessions = sessions.filter(state__flow_uuid=str(flow_uuid))
latest_session = sessions.first()
if not latest_session: if not latest_session:
return { return {
@ -677,10 +712,12 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"role_name": role.name, "role_name": role.name,
"latest_status": "not_started", "latest_status": "not_started",
"session_count": 0, "session_count": 0,
"flow_exists": bool(active_flow), "flow_exists": bool(scoped_flow or active_flow),
"flow_uuid": str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None,
"progress": 0, "progress": 0,
"responses_count": 0, "responses_count": 0,
"completed_modules": [], "completed_modules": [],
"is_completed": False,
} }
state = latest_session.state or {} state = latest_session.state or {}
@ -693,9 +730,31 @@ class OnboardingConsumer(AsyncWebsocketConsumer):
"role_name": role.name, "role_name": role.name,
"latest_status": latest_session.status, "latest_status": latest_session.status,
"session_count": sessions.count(), "session_count": sessions.count(),
"flow_exists": bool(active_flow), "flow_exists": bool(scoped_flow or active_flow),
"flow_uuid": str((scoped_flow or active_flow).uuid) if (scoped_flow or active_flow) else None,
"progress": progress, "progress": progress,
"responses_count": len(responses) if isinstance(responses, dict) else 0, "responses_count": len(responses) if isinstance(responses, dict) else 0,
"completed_modules": completed_modules if isinstance(completed_modules, list) else [], "completed_modules": completed_modules if isinstance(completed_modules, list) else [],
"updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None, "updated_at": latest_session.updated_at.isoformat() if latest_session.updated_at else None,
} "is_completed": latest_session.status == 'completed',
}
@database_sync_to_async
def resolve_target_user_id(self, role_uuid, requester_id, target_user_uuid):
from apps.accounts.models import Role, User
role = Role.objects.filter(uuid=role_uuid).first()
requester = User.objects.filter(id=requester_id).first()
target = User.objects.filter(uuid=target_user_uuid).first()
if role is None or requester is None or target is None:
return None
is_owner = role.organization.owner.id == requester_id
is_manager_member = bool(requester.is_manager) and role.organization.members.filter(id=requester_id).exists()
if not (is_owner or is_manager_member):
return None
if not role.members.filter(id=target.id).exists():
return None
return target.id

View file

@ -144,6 +144,7 @@ export const API = {
}, },
sessions: { sessions: {
list: () => 'onboarding-session/', list: () => 'onboarding-session/',
progressOverview: () => 'onboarding-session/progress-overview/',
byId: (uuid: string) => `onboarding-session/${uuid}/`, byId: (uuid: string) => `onboarding-session/${uuid}/`,
interact: (uuid: string) => `onboarding-session/${uuid}/interact/`, interact: (uuid: string) => `onboarding-session/${uuid}/interact/`,
askKa: (uuid: string) => `onboarding-session/${uuid}/ask-ka/`, askKa: (uuid: string) => `onboarding-session/${uuid}/ask-ka/`,

View file

@ -29,7 +29,6 @@ export type OnboardingFlow = {
agent?: string | null agent?: string | null
title: string title: string
description?: string description?: string
status: 'draft' | 'published' | 'archived'
pages?: OnboardingPage[] pages?: OnboardingPage[]
} }
@ -63,6 +62,12 @@ export type ProgressSessionApi = {
uuid: string uuid: string
status: OnboardingSessionStatus status: OnboardingSessionStatus
role: UuidNameRef role: UuidNameRef
user?: {
uuid: string
email_address?: string
first_name?: string
last_name?: string
}
updated_at?: string updated_at?: string
state?: Record<string, unknown> state?: Record<string, unknown>
} }
@ -81,3 +86,26 @@ export type RoleProgressItem = {
feedback?: string feedback?: string
loadingFeedback: boolean loadingFeedback: boolean
} }
export type ProgressOverviewItem = {
role: UuidNameRef
user: {
uuid: string
name: string
email: string
}
flow: {
uuid: string
title: string
is_active: boolean
}
latest_status: string
progress: number
is_completed: boolean
latest_session_uuid?: string | null
updated_at?: string | null
}
export type FlowLookup = {
title?: string
}

View file

@ -16,6 +16,7 @@ export interface Role {
name: string name: string
description?: string description?: string
organization: Organization organization: Organization
members?: User[]
member_count?: number member_count?: number
created_at: string created_at: string
updated_at: string updated_at: string

View file

@ -1,21 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue' import { Card, Typography, Button, Spin, Tag, List, message } from 'ant-design-vue'
import { apiClient, API } from '../router/api' import { apiClient, API } from '../router/api'
import type { Role } from '../types/organization' import type { ProgressSessionApi, FlowLookup } from '../types/onboarding'
import type { ProgressSessionApi } from '../types/onboarding'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const roleId = computed(() => route.params.roleId as string) const roleId = computed(() => route.params.roleId as string)
const selectedUserUuid = computed(() => String(route.query.user_uuid || ''))
const selectedFlowUuid = computed(() => String(route.query.flow_uuid || ''))
const loading = ref(false) const loading = ref(false)
const monitoring = ref(false) const monitoring = ref(false)
const role = ref<Role | null>(null) const roleName = ref('Unknown Role')
const learnerName = ref('Learner')
const flowTitle = ref('')
const sessions = ref<ProgressSessionApi[]>([]) const sessions = ref<ProgressSessionApi[]>([])
const feedback = ref<string>('') const feedback = ref<string>('')
const monitorLogs = ref<string[]>([]) const monitorLogs = ref<string[]>([])
const monitorSocket = ref<WebSocket | null>(null)
const monitorTimeout = ref<number | null>(null)
const latestSession = computed(() => sessions.value[0] || null) const latestSession = computed(() => sessions.value[0] || null)
@ -24,21 +29,44 @@ const websocketUrl = (id: string) => {
return `${protocol}://${window.location.host}/ws/onboarding/${id}/` return `${protocol}://${window.location.host}/ws/onboarding/${id}/`
} }
const closeMonitorSocket = () => {
if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
if (monitorSocket.value) {
monitorSocket.value.close()
monitorSocket.value = null
}
}
const runProgressMonitor = async () => { const runProgressMonitor = async () => {
monitoring.value = true monitoring.value = true
monitorLogs.value = [] monitorLogs.value = []
try { try {
closeMonitorSocket()
const ws = new WebSocket(websocketUrl(roleId.value)) const ws = new WebSocket(websocketUrl(roleId.value))
monitorSocket.value = ws
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => { let settled = false
monitorTimeout.value = window.setTimeout(() => {
settled = true
ws.close() ws.close()
reject(new Error('Progress monitor timed out')) reject(new Error('Progress monitor timed out'))
}, 30000) }, 30000)
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ action: 'progress_monitor', role_uuid: roleId.value })) ws.send(
JSON.stringify({
action: 'progress_monitor',
role_uuid: roleId.value,
user_uuid: selectedUserUuid.value || undefined,
flow_uuid: selectedFlowUuid.value || undefined,
}),
)
} }
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -52,28 +80,55 @@ const runProgressMonitor = async () => {
feedback.value = String( feedback.value = String(
payload.content?.feedback || payload.message || 'No feedback returned.', payload.content?.feedback || payload.message || 'No feedback returned.',
) )
window.clearTimeout(timeout) if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
settled = true
ws.close() ws.close()
resolve() resolve()
} }
if (payload.type === 'error') { if (payload.type === 'error') {
window.clearTimeout(timeout) if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
settled = true
ws.close() ws.close()
reject(new Error(payload.message || 'Progress monitor failed')) reject(new Error(payload.message || 'Progress monitor failed'))
} }
} catch { } catch {
window.clearTimeout(timeout) if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
settled = true
ws.close() ws.close()
reject(new Error('Invalid monitor response')) reject(new Error('Invalid monitor response'))
} }
} }
ws.onerror = () => { ws.onerror = () => {
window.clearTimeout(timeout) if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
settled = true
ws.close() ws.close()
reject(new Error('Progress monitor websocket error')) reject(new Error('Progress monitor websocket error'))
} }
ws.onclose = () => {
if (monitorTimeout.value !== null) {
window.clearTimeout(monitorTimeout.value)
monitorTimeout.value = null
}
monitorSocket.value = null
if (!settled) {
reject(new Error('Progress monitor connection closed before completion'))
}
}
}) })
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : 'Failed to run progress monitor') message.error(error instanceof Error ? error.message : 'Failed to run progress monitor')
@ -85,21 +140,40 @@ const runProgressMonitor = async () => {
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
try { try {
const [rolesRes, sessionsRes] = await Promise.all([ const sessionsRes = await apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list(), {
apiClient.get<Role[]>(API.roles.mine()), params: {
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()), role_uuid: roleId.value,
]) user_uuid: selectedUserUuid.value || undefined,
flow_uuid: selectedFlowUuid.value || undefined,
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : [] },
role.value = roles.find((r) => r.uuid === roleId.value) || null })
const allSessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : [] const allSessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : []
sessions.value = allSessions sessions.value = allSessions.sort(
.filter((session) => session.role?.uuid === roleId.value) (a, b) => new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(),
.sort( )
(a, b) =>
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(), if (sessions.value[0]?.role?.name) {
roleName.value = String(sessions.value[0].role.name)
}
if (sessions.value[0]?.user?.first_name || sessions.value[0]?.user?.last_name) {
const first = String(sessions.value[0].user.first_name || '')
const last = String(sessions.value[0].user.last_name || '')
learnerName.value = `${first} ${last}`.trim()
}
if (!sessions.value.length) {
feedback.value = 'No onboarding session found for this learner and flow yet.'
monitorLogs.value = []
return
}
if (selectedFlowUuid.value) {
const flowRes = await apiClient.get<FlowLookup>(
API.onboarding.flows.byId(selectedFlowUuid.value),
) )
flowTitle.value = String(flowRes.data?.title || '')
}
await runProgressMonitor() await runProgressMonitor()
} catch { } catch {
@ -112,6 +186,14 @@ const loadData = async () => {
onMounted(() => { onMounted(() => {
loadData() loadData()
}) })
onBeforeUnmount(() => {
closeMonitorSocket()
})
onBeforeRouteLeave(() => {
closeMonitorSocket()
})
</script> </script>
<template> <template>
@ -123,7 +205,11 @@ onMounted(() => {
<Card class="panel" :bordered="false"> <Card class="panel" :bordered="false">
<Spin :spinning="loading" tip="Loading role progress..."> <Spin :spinning="loading" tip="Loading role progress...">
<Typography.Title :level="4">{{ role?.name || 'Unknown Role' }}</Typography.Title> <Typography.Title :level="4">{{ roleName }}</Typography.Title>
<Typography.Paragraph type="secondary">
Learner: {{ learnerName }}
<span v-if="flowTitle"> | Flow: {{ flowTitle }}</span>
</Typography.Paragraph>
<div class="status-row"> <div class="status-row">
<Typography.Text strong>Latest Status:</Typography.Text> <Typography.Text strong>Latest Status:</Typography.Text>

View file

@ -3,107 +3,19 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Card, Typography, List, Tag, Button, Spin, message } from 'ant-design-vue' import { Card, Typography, List, Tag, Button, Spin, message } from 'ant-design-vue'
import { apiClient, API } from '../router/api' import { apiClient, API } from '../router/api'
import type { Role } from '../types/organization' import type { ProgressOverviewItem } from '../types/onboarding'
import type { ProgressSessionApi, ProgressFlowApi, RoleProgressItem } from '../types/onboarding'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const rows = ref<RoleProgressItem[]>([]) const rows = ref<ProgressOverviewItem[]>([])
const websocketUrl = (roleId: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${window.location.host}/ws/onboarding/${roleId}/`
}
const runProgressMonitor = (roleId: string): Promise<string> =>
new Promise((resolve, reject) => {
const ws = new WebSocket(websocketUrl(roleId))
const timeout = window.setTimeout(() => {
ws.close()
reject(new Error('Progress monitor timed out'))
}, 30000)
ws.onopen = () => {
ws.send(
JSON.stringify({
action: 'progress_monitor',
role_uuid: roleId,
}),
)
}
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data)
if (payload.type === 'completed') {
window.clearTimeout(timeout)
const feedback =
payload.content?.feedback || payload.message || 'No feedback returned.'
ws.close()
resolve(String(feedback))
} else if (payload.type === 'error') {
window.clearTimeout(timeout)
ws.close()
reject(new Error(payload.message || 'Progress monitor failed'))
}
} catch {
window.clearTimeout(timeout)
ws.close()
reject(new Error('Invalid monitor response'))
}
}
ws.onerror = () => {
window.clearTimeout(timeout)
ws.close()
reject(new Error('Progress monitor websocket error'))
}
})
const loadProgress = async () => { const loadProgress = async () => {
loading.value = true loading.value = true
try { try {
const [rolesRes, sessionsRes, flowsRes] = await Promise.all([ const overviewRes = await apiClient.get<ProgressOverviewItem[]>(
apiClient.get<Role[]>(API.roles.mine()), API.onboarding.sessions.progressOverview(),
apiClient.get<ProgressSessionApi[]>(API.onboarding.sessions.list()), )
apiClient.get<ProgressFlowApi[]>(API.onboarding.flows.list()), rows.value = Array.isArray(overviewRes.data) ? overviewRes.data : []
])
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : []
const sessions = Array.isArray(sessionsRes.data) ? sessionsRes.data : []
const flows = Array.isArray(flowsRes.data) ? flowsRes.data : []
rows.value = roles.map((role) => {
const roleSessions = sessions
.filter((session) => session.role?.uuid === role.uuid)
.sort(
(a, b) =>
new Date(b.updated_at || 0).getTime() -
new Date(a.updated_at || 0).getTime(),
)
const latestSession = roleSessions[0]
const flow = flows.find((f) => f.role?.uuid === role.uuid)
return {
role,
latestStatus: latestSession?.status || 'not_started',
latestSessionUuid: latestSession?.uuid,
flowTitle: flow?.title,
feedback: undefined,
loadingFeedback: true,
}
})
for (const row of rows.value) {
try {
row.feedback = await runProgressMonitor(row.role.uuid)
} catch (error) {
row.feedback =
error instanceof Error ? error.message : 'Unable to fetch monitor feedback.'
} finally {
row.loadingFeedback = false
}
}
} catch { } catch {
message.error('Failed to load progress overview') message.error('Failed to load progress overview')
} finally { } finally {
@ -129,39 +41,40 @@ onMounted(() => {
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<List.Item> <List.Item>
<List.Item.Meta <List.Item.Meta
:title="item.role.name" :title="`${item.role.name} · ${item.user.name}`"
:description="item.flowTitle || 'No active flow yet'" :description="item.flow.title"
/> />
<div class="row-meta"> <div class="row-meta">
<Tag <Tag
:color=" :color="
item.latestStatus === 'completed' item.latest_status === 'completed'
? 'green' ? 'green'
: item.latestStatus === 'active' : item.latest_status === 'active'
? 'blue' ? 'blue'
: 'default' : 'default'
" "
> >
{{ item.latestStatus }} {{ item.latest_status }}
</Tag>
<Tag color="gold">{{ item.progress }}%</Tag>
<Tag :color="item.flow.is_active ? 'cyan' : 'default'">
{{ item.flow.is_active ? 'active flow' : 'inactive flow' }}
</Tag> </Tag>
<Button <Button
type="primary" type="primary"
@click="router.push(`/progress/${item.role.uuid}`)" @click="
router.push({
path: `/progress/${item.role.uuid}`,
query: {
user_uuid: item.user.uuid,
flow_uuid: item.flow.uuid,
},
})
"
> >
View Details View Details
</Button> </Button>
</div> </div>
<div class="feedback-block">
<Typography.Text strong>Progress Monitor:</Typography.Text>
<Spin
v-if="item.loadingFeedback"
size="small"
style="margin-left: 0.5rem"
/>
<Typography.Paragraph v-else class="feedback-text">
{{ item.feedback || 'No feedback yet.' }}
</Typography.Paragraph>
</div>
</List.Item> </List.Item>
</template> </template>
</List> </List>
@ -183,15 +96,8 @@ onMounted(() => {
.row-meta { .row-meta {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.feedback-block {
margin-top: 0.8rem;
}
.feedback-text {
margin-top: 0.4rem;
color: #6b7280;
white-space: pre-wrap;
}
</style> </style>