feat(desktop): wire LspStatusIndicator into WorkspacePanel (#51)

Replaces the bare 'LSP {state} . N diagnostics' span in the file preview header with the existing LspStatusIndicator component, which was already authored, tested, and shipped on main but had no parent rendering it.

What you now get on the workspace file preview header:

  - Spinning loader for starting/idle, green check for ready, red alert with N error count when ready+errors, red retry button when unavailable

  - Click the pill to open a diagnostics dropdown listing path:line:col plus truncated message; Enter on a row opens that file in the preview

  - Retry button round-trips POST /api/sessions/:id/lsp/restart and re-reads state, since LspManager keeps unavailable workspaces sticky until an explicit restart

Type bridge:

  - The server wire shape (WorkspaceLspState: idle/starting/ready/unavailable with an error string) does not carry LspUnavailableReason, but LspStatusIndicator was authored against the legacy four-state shape with reason+errorCount

  - New pure adapter desktop/src/lib/lspStateMap.ts maps the two shapes: idle-as-starting, ready threads errorCount derived from severity=error diagnostics, unavailable collapses to init-failed (Retry path)

Tested:

  - bunx vitest src/lib/lspStateMap.test.ts (8 cases — all four state transitions plus diagnostic severity counting)

  - bunx vitest LspStatusIndicator + workspacePanelStore — 53/53 pass

  - bun run lint (tsc --noEmit) clean

Not-tested: live-LSP retry round-trip (covered by lspManager server tests separately)

Confidence: high

Scope-risk: narrow

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-16 00:19:11 +08:00 committed by GitHub
parent 355b7294b9
commit 149d6a9b44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 174 additions and 11 deletions

View File

@ -31,6 +31,9 @@ import {
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
import { WorkspaceEditor, saveWorkspaceBuffer } from './WorkspaceEditor'
import { UnsavedChangesModal } from './UnsavedChangesModal'
import { LspStatusIndicator } from './LspStatusIndicator'
import { errorCountFromDiagnostics, toLegacyLspState } from '../../lib/lspStateMap'
import { sessionsApi } from '../../api/sessions'
type WorkspacePanelProps = {
sessionId: string
@ -981,6 +984,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
const initBuffer = useWorkspacePanelStore((state) => state.initBuffer)
const syncLsp = useWorkspacePanelStore((state) => state.syncLsp)
const loadLspState = useWorkspacePanelStore((state) => state.loadLspState)
const bufferStateByTabId = useWorkspacePanelStore((state) => state.bufferStateByTabId)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
@ -1354,17 +1358,26 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
))}
<div className="ml-auto flex items-center gap-2">
{activePreviewTab.kind === 'file' && (
<span
data-testid="workspace-lsp-summary"
className={`shrink-0 rounded-[5px] border px-1.5 py-0.5 text-[10px] font-medium ${
activeLspDiagnostics?.diagnosticsTotal
? 'border-[var(--color-error)]/30 text-[var(--color-error)]'
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)]'
}`}
title={lspState?.error ?? lspState?.command ?? undefined}
>
{t('workspace.lspState', { state: lspState?.state ?? 'idle' })} · {t('workspace.lspDiagnostics', { count: activeLspDiagnostics?.diagnosticsTotal ?? 0 })}
</span>
<LspStatusIndicator
state={toLegacyLspState(
lspState,
errorCountFromDiagnostics(activeLspDiagnostics?.diagnostics),
sessionId,
)}
diagnostics={activeLspDiagnostics?.diagnostics ?? []}
onRetryClick={() => {
// Server-side LspManager keeps `unavailable` workspaces
// sticky until an explicit restart, so this round-trips
// through /lsp/restart and then re-reads state.
void sessionsApi
.restartWorkspaceLsp(sessionId, { path: activePreviewTab.path })
.catch(() => undefined)
.then(() => loadLspState(sessionId, activePreviewTab.path))
}}
onDiagnosticOpen={(diagnostic) => {
void openPreview(sessionId, diagnostic.path, 'file')
}}
/>
)}
<button
type="button"

View File

@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { errorCountFromDiagnostics, toLegacyLspState } from './lspStateMap'
import type { LspDiagnostic, WorkspaceLspState } from '../types/lsp'
describe('toLegacyLspState', () => {
it('returns starting when state is undefined (never loaded)', () => {
const result = toLegacyLspState(undefined, 0, 'w1')
expect(result).toEqual({ state: 'starting', workspaceId: 'w1', errorCount: 0 })
})
it('treats idle as starting so the pill does not flash unavailable', () => {
const idle: WorkspaceLspState = { state: 'idle', path: null, serverName: null, command: null }
expect(toLegacyLspState(idle, 0, 'w1')).toEqual({
state: 'starting',
workspaceId: 'w1',
errorCount: 0,
})
})
it('passes through starting', () => {
const starting: WorkspaceLspState = { state: 'starting', path: null, serverName: null, command: null }
expect(toLegacyLspState(starting, 0, 'w1')).toEqual({
state: 'starting',
workspaceId: 'w1',
errorCount: 0,
})
})
it('passes through ready and threads errorCount', () => {
const ready: WorkspaceLspState = {
state: 'ready',
path: 'src/app.ts',
serverName: 'typescript',
command: 'typescript-language-server',
}
expect(toLegacyLspState(ready, 3, 'w1')).toEqual({
state: 'ready',
workspaceId: 'w1',
errorCount: 3,
})
})
it('maps unavailable to init-failed (Retry button) since wire shape lacks reason', () => {
const unavailable: WorkspaceLspState = {
state: 'unavailable',
path: null,
serverName: null,
command: null,
error: 'spawn ENOENT typescript-language-server',
}
expect(toLegacyLspState(unavailable, 0, 'w1')).toEqual({
state: 'unavailable',
workspaceId: 'w1',
reason: 'init-failed',
errorCount: 0,
lastStderrTail: 'spawn ENOENT typescript-language-server',
})
})
it('omits lastStderrTail when error string is absent', () => {
const unavailable: WorkspaceLspState = {
state: 'unavailable',
path: null,
serverName: null,
command: null,
}
const result = toLegacyLspState(unavailable, 0, 'w1')
expect(result).toEqual({
state: 'unavailable',
workspaceId: 'w1',
reason: 'init-failed',
errorCount: 0,
})
})
})
describe('errorCountFromDiagnostics', () => {
function diag(severity: LspDiagnostic['severity']): LspDiagnostic {
return { path: 'a.ts', line: 1, column: 1, severity, message: 'm' }
}
it('returns 0 when undefined or empty', () => {
expect(errorCountFromDiagnostics(undefined)).toBe(0)
expect(errorCountFromDiagnostics([])).toBe(0)
})
it('counts only error severity, not warning/info/hint', () => {
expect(
errorCountFromDiagnostics([
diag('error'),
diag('warning'),
diag('error'),
diag('info'),
diag('hint'),
]),
).toBe(2)
})
})

View File

@ -0,0 +1,51 @@
import type {
LspDiagnostic,
LegacyWorkspaceLspState,
WorkspaceLspState,
} from '../types/lsp'
/**
* Map the server-side {@link WorkspaceLspState} (one shape, four states
* including `idle`) onto the legacy four-state shape that
* {@link LspStatusIndicator} consumes.
*
* The indicator was authored against an earlier wire shape that exposed an
* `LspUnavailableReason`. The current server response only carries an `error`
* string, so unavailable states collapse to `init-failed` (Retry button) and
* never trigger the prereq-missing path. That is acceptable because the
* desktop's prereq UX lives in `PluginPrerequisitesModal`, reached from the
* Plugins page, not the workspace pill.
*/
export function toLegacyLspState(
state: WorkspaceLspState | undefined,
errorCount: number,
workspaceId: string,
): LegacyWorkspaceLspState {
// No state yet (loading, or never queried) — present as `starting` so the
// pill spins instead of flashing "unavailable".
if (!state || state.state === 'idle' || state.state === 'starting') {
return { state: 'starting', workspaceId, errorCount: 0 }
}
if (state.state === 'ready') {
return { state: 'ready', workspaceId, errorCount }
}
return {
state: 'unavailable',
workspaceId,
reason: 'init-failed',
errorCount: 0,
...(state.error ? { lastStderrTail: state.error } : {}),
}
}
/** Count how many diagnostics in a list are errors (not warnings/info/hints). */
export function errorCountFromDiagnostics(
diagnostics: readonly LspDiagnostic[] | undefined,
): number {
if (!diagnostics || diagnostics.length === 0) return 0
let count = 0
for (const d of diagnostics) {
if (d.severity === 'error') count += 1
}
return count
}