feat(web): define ChatEvent discriminated union, eliminate all any sites (R9)
Created web/src/lib/types.ts with discriminated unions for SSE event payloads: ChatEvent (7 variants: session, tool_use, tool_result, text_delta, text, done, error), ToolCallResult, MessageContent, and typed data shapes for live events (PlanProposedData, PlanStepEventData, QuestionRaisedData, QuestionAnsweredData, EntityTouchedData, HealthChangedData) plus WailsGlobal for the desktop bridge. Replaced all ~15 `any` sites across 7 files: - api.ts: Message.content any -> MessageContent | string; removed local ChatEvent interface (now imported from types.ts as a discriminated union); JSON.parse cast to ChatEvent. - stores/chat.ts: removed local ToolCallResult interface (imported from types.ts, re-exported for backward compat); extractApprovals accesses args with typeof guards instead of implicit any access; toChatMessages handles string|object Message.content cleanly. - stores/activity.ts: update_plan_step seq/status extracted via typeof guards instead of `as any` casts; toolActivityLabel uses a str() helper for safe string extraction from unknown args. - stores/workspace.ts: applyPlanStepEvent takes PlanStepEventData; applyEvent casts data to Record<string, unknown>; switch cases cast to typed interfaces (PlanProposedData, QuestionRaisedData, etc.) instead of `as any`; applyHealthChanged uses HealthChangedData. - Config.svelte: (window as any).wails -> typed WailsGlobal cast; catch (e: any) -> catch (e: unknown) with instanceof Error check. - utils.ts: WithoutChild/WithoutChildren `any` -> `unknown`. - vite.config.ts: authProxy proxy/proxyReq `any` -> ProxyOptions type. Result: eslint no-explicit-any warnings dropped 12 -> 0. Tests (6/6) and build pass. VERSION 0.7.10 -> 0.7.11. Plan R9 marked done.
This commit is contained in:
@@ -2,6 +2,14 @@ import { writable, derived, get } from 'svelte/store'
|
||||
import { liveEvents, subscribeEvents } from './events'
|
||||
import { currentSession, sessions, loadSessions } from './chat'
|
||||
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
|
||||
import type {
|
||||
PlanProposedData,
|
||||
PlanStepEventData,
|
||||
QuestionRaisedData,
|
||||
QuestionAnsweredData,
|
||||
EntityTouchedData,
|
||||
HealthChangedData
|
||||
} from '$lib/types'
|
||||
|
||||
// workspace.ts is the live "what is this task doing right now" surface for the
|
||||
// TaskContextPanel: plan progress, the pinned operator question, and entities
|
||||
@@ -65,9 +73,9 @@ async function hydrate(sessionId: string) {
|
||||
questions.set(qs)
|
||||
}
|
||||
|
||||
function applyPlanStepEvent(sessionId: string, type: string, data: any) {
|
||||
const stepID = data?.step_id as string | undefined
|
||||
const seq = data?.seq as number | undefined
|
||||
function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) {
|
||||
const stepID = data?.step_id
|
||||
const seq = data?.seq
|
||||
planSteps.update((steps) => {
|
||||
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
|
||||
if (i === -1) return steps
|
||||
@@ -80,7 +88,7 @@ function applyPlanStepEvent(sessionId: string, type: string, data: any) {
|
||||
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
|
||||
const sid = get(currentSession)
|
||||
if (!sid || ev.correlation_id !== sid) return
|
||||
const data = (ev.data ?? {}) as any
|
||||
const data = (ev.data ?? {}) as Record<string, unknown>
|
||||
|
||||
// Task fields (status/goal/outcome) live on the session row — refetch the
|
||||
// (cheap) session list so the UI picks up the change without a
|
||||
@@ -99,46 +107,49 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'plan.proposed':
|
||||
if (Array.isArray(data.steps)) {
|
||||
const incoming = data.steps.map((s: any) => ({
|
||||
case 'plan.proposed': {
|
||||
const d = data as unknown as PlanProposedData
|
||||
if (Array.isArray(d.steps)) {
|
||||
const incoming = d.steps.map((s) => ({
|
||||
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
|
||||
status: 'pending' as const, target_slug: s.target_slug || undefined
|
||||
}))
|
||||
// The server appends rather than replaces once any step has started
|
||||
// (see store.go proposePlan) — mirror that here so a model that calls
|
||||
// propose_plan once per step still shows the FULL running history in
|
||||
// the panel, not just its latest call's single step.
|
||||
planSteps.update((existing) => (data.appended ? [...existing, ...incoming] : incoming))
|
||||
planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'plan.step.started':
|
||||
case 'plan.step.finished':
|
||||
applyPlanStepEvent(sid, ev.type, data)
|
||||
applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData)
|
||||
break
|
||||
case 'question.raised':
|
||||
case 'question.raised': {
|
||||
const d = data as unknown as QuestionRaisedData
|
||||
questions.update((qs) => [
|
||||
{
|
||||
id: data.question_id, prompt: data.prompt ?? '',
|
||||
context: { why: data.why, options: data.options, entities: data.entities },
|
||||
id: d.question_id, prompt: d.prompt ?? '',
|
||||
context: { why: d.why, options: d.options, entities: d.entities },
|
||||
status: 'open', created_at: new Date().toISOString()
|
||||
},
|
||||
...qs.filter((q) => q.id !== data.question_id)
|
||||
...qs.filter((q) => q.id !== d.question_id)
|
||||
])
|
||||
break
|
||||
case 'question.answered':
|
||||
}
|
||||
case 'question.answered': {
|
||||
const d = data as unknown as QuestionAnsweredData
|
||||
questions.update((qs) =>
|
||||
qs.map((q) => (q.id === data.question_id ? { ...q, status: 'answered', answer: data.answer } : q))
|
||||
qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q))
|
||||
)
|
||||
break
|
||||
case 'entity.touched':
|
||||
if (data.slug) {
|
||||
}
|
||||
case 'entity.touched': {
|
||||
const d = data as unknown as EntityTouchedData
|
||||
if (d.slug) {
|
||||
const now = Date.now()
|
||||
touched.update((t) => [{ slug: data.slug, tool: data.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
|
||||
touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'knowledge.recorded':
|
||||
// No dedicated store — knowledge cards refetch on completion signal.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -148,11 +159,11 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
// by correlation_id (health events don't carry one).
|
||||
function applyHealthChanged(ev: { type: string; data?: unknown }) {
|
||||
if (ev.type !== 'health.changed') return
|
||||
const data = (ev.data ?? {}) as any
|
||||
const data = (ev.data ?? {}) as HealthChangedData
|
||||
if (!data.slug) return
|
||||
const isRelevant = get(touched).some((t) => t.slug === data.slug)
|
||||
if (!isRelevant) return
|
||||
healthDiffs.update((d) => [{ slug: data.slug, from: data.from, to: data.to, ts: Date.now() }, ...d].slice(0, 8))
|
||||
healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
|
||||
}
|
||||
|
||||
// startWorkspace opens the global event subscription and begins tracking the
|
||||
|
||||
Reference in New Issue
Block a user