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:
2026-07-17 23:08:35 +02:00
parent 7dc1c1ae39
commit 6806fac5fd
10 changed files with 184 additions and 67 deletions

View File

@@ -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