feat: add native agent rich output (#2773)
This commit is contained in:
@@ -17,6 +17,7 @@ Available tools:
|
||||
- get_task_plan_kandev: Read a task plan, including user edits.
|
||||
- update_task_plan_kandev: Update an existing task plan.
|
||||
- delete_task_plan_kandev: Delete a task plan.
|
||||
- show_rich_output_kandev: When user asks for chart/graph/plot/file preview/KPI/metrics with data: call now. Do not implement the display as ASCII/SVG/HTML or with another app. Else prose; small text table: Markdown. Send version=1,title,blocks (1-4). Inline: {"type":"chart","chart_type":"bar","title":"T","summary":"S","labels":["A","B"],"series":[{"label":"Count","values":[42,27]}]}. CSV line: {"type":"chart","chart_type":"line","title":"T","summary":"S","csv":{"path":"reports/latency.csv","x_column":"recorded_at","series":[{"column":"p95_ms","label":"p95 (ms)"}]}}. Metrics: {"type":"metrics","items":[{"label":"Passed","value":"38"}]}. Paths workspace-relative. Kandev owns axes/legends/tooltips/layout. Label series with units.
|
||||
- show_walkthrough_kandev: Store an ordered, file-anchored code walkthrough.
|
||||
- get_walkthrough_kandev: Read a task's stored walkthrough.
|
||||
- delete_walkthrough_kandev: Delete a task's walkthrough.
|
||||
|
||||
@@ -11,6 +11,7 @@ Available tools:
|
||||
- get_task_plan_kandev: Retrieve the current task plan, including user edits. Required: task_id.
|
||||
- update_task_plan_kandev: Update the current task plan. Required: task_id, content. Optional: title.
|
||||
- delete_task_plan_kandev: Delete the current task plan. Required: task_id.
|
||||
- show_rich_output_kandev: When user asks for chart/graph/plot/file preview/KPI/metrics with data: call now. Do not implement the display as ASCII/SVG/HTML or with another app. Else prose; small text table: Markdown. Send version=1,title,blocks (1-4). Inline: {"type":"chart","chart_type":"bar","title":"T","summary":"S","labels":["A","B"],"series":[{"label":"Count","values":[42,27]}]}. CSV line: {"type":"chart","chart_type":"line","title":"T","summary":"S","csv":{"path":"reports/latency.csv","x_column":"recorded_at","series":[{"column":"p95_ms","label":"p95 (ms)"}]}}. Metrics: {"type":"metrics","items":[{"label":"Passed","value":"38"}]}. Paths workspace-relative. Kandev owns axes/legends/tooltips/layout. Label series with units.
|
||||
- list_related_tasks_kandev: List parent, child, sibling, blocker, and blocked tasks. Optional: task_id (defaults to the current task), verbose (include task descriptions; omitted by default).
|
||||
- list_task_documents_kandev: List documents on an accessible related task. Required: task_id.
|
||||
- get_task_document_kandev: Read one document on an accessible related task. Required: task_id, document_key.
|
||||
|
||||
@@ -127,7 +127,12 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT
|
||||
}
|
||||
|
||||
toolKind := string(tc.Kind)
|
||||
toolName := toolKind
|
||||
normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args)
|
||||
if frame, ok := a.dialect.parseMCPToolCall(tc.Meta, tc.RawInput); ok {
|
||||
toolName = frame.name
|
||||
normalizedPayload = streams.NewMCPTool(frame.name, frame.arguments)
|
||||
}
|
||||
|
||||
toolCallID := string(tc.ToolCallId)
|
||||
normalizedPayload, eventType, codexSignal, emittedToolCallID, codexParentToolCallID, suppress := a.trackToolCallPayload(
|
||||
@@ -147,7 +152,7 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT
|
||||
a.handleWakeupEvent(sessionID, toolCallID, tc.Meta, tc.RawInput, false)
|
||||
|
||||
// Detect tool type for logging
|
||||
toolType := DetectToolOperationType(toolKind, args)
|
||||
toolType := DetectToolOperationType(toolName, args)
|
||||
_ = toolType // Used for normalization
|
||||
|
||||
status := string(tc.Status)
|
||||
@@ -168,7 +173,7 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT
|
||||
SessionID: sessionID,
|
||||
ToolCallID: emittedToolCallID,
|
||||
ParentToolCallID: parentToolCallID,
|
||||
ToolName: toolKind, // Kind is effectively the tool name
|
||||
ToolName: toolName,
|
||||
ToolTitle: tc.Title,
|
||||
ToolStatus: status,
|
||||
NormalizedPayload: normalizedPayload,
|
||||
@@ -657,7 +662,8 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session
|
||||
return nil
|
||||
}
|
||||
monitorCommand := ""
|
||||
if payload != nil && (tcu.RawInput != nil || len(tcu.Locations) > 0) {
|
||||
canonicalMCPUpdate := payload != nil && payload.IsMCPTool() && payload.Generic() != nil
|
||||
if payload != nil && !canonicalMCPUpdate && (tcu.RawInput != nil || len(tcu.Locations) > 0) {
|
||||
a.normalizer.UpdatePayloadInput(payload, tcu.RawInput, supplemental)
|
||||
}
|
||||
if payload != nil && (tcu.Title != nil || tcu.Meta != nil || tcu.RawInput != nil || supplemental != nil) {
|
||||
@@ -703,7 +709,13 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session
|
||||
if payload != nil && payload.Kind() == streams.ToolKindShellExec {
|
||||
recognizedShellUpdate = a.normalizer.NormalizeShellToolUpdate(payload, tcu.Meta, convertedContents, tcu.RawOutput)
|
||||
} else if tcu.RawOutput != nil && payload != nil && !isTrackedMonitorTerminal {
|
||||
a.normalizer.NormalizeToolResult(payload, tcu.RawOutput)
|
||||
result := tcu.RawOutput
|
||||
if canonicalMCPUpdate {
|
||||
if normalizedResult, ok := a.dialect.normalizeMCPToolResult(tcu.RawOutput); ok {
|
||||
result = normalizedResult
|
||||
}
|
||||
}
|
||||
a.normalizer.NormalizeToolResult(payload, result)
|
||||
}
|
||||
var shellExitCode *int
|
||||
if payload != nil && payload.ShellExec() != nil && payload.ShellExec().Output != nil {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
acpsdk "github.com/coder/acp-go-sdk"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodexMCPExecuteFrameNormalizesAsGenericToolCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := newTestAdapter()
|
||||
a.agentID = codexAgentID
|
||||
a.normalizer = NewNormalizer(codexAgentID)
|
||||
a.dialect = newACPDialect(codexAgentID)
|
||||
|
||||
arguments := map[string]any{
|
||||
"version": float64(1),
|
||||
"title": "Failed requests",
|
||||
"blocks": []any{map[string]any{
|
||||
"type": "chart",
|
||||
"chart_type": "bar",
|
||||
"title": "Failed requests by service",
|
||||
"summary": "Failures in the latest interval.",
|
||||
"labels": []any{"API", "Billing"},
|
||||
"series": []any{map[string]any{
|
||||
"label": "Failed requests",
|
||||
"values": []any{float64(42), float64(27)},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
rawInput := map[string]any{
|
||||
"server": "kandev",
|
||||
"tool": "show_rich_output_kandev",
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
initial := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{
|
||||
Meta: map[string]any{"is_mcp_tool_call": true},
|
||||
Kind: acpsdk.ToolKind("execute"),
|
||||
RawInput: rawInput,
|
||||
SessionUpdate: "tool_call",
|
||||
Status: acpsdk.ToolCallStatus("in_progress"),
|
||||
Title: "mcp.kandev.show_rich_output_kandev",
|
||||
ToolCallId: acpsdk.ToolCallId("mcp-call-1"),
|
||||
})
|
||||
|
||||
require.NotNil(t, initial)
|
||||
require.Equal(t, streams.EventTypeToolCall, initial.Type)
|
||||
require.Equal(t, "kandev/show_rich_output_kandev", initial.ToolName)
|
||||
require.Equal(t, streams.ToolKindGeneric, initial.NormalizedPayload.Kind())
|
||||
require.True(t, initial.NormalizedPayload.IsMCPTool())
|
||||
require.Equal(t, "kandev/show_rich_output_kandev", initial.NormalizedPayload.Generic().Name)
|
||||
require.Equal(t, arguments, initial.NormalizedPayload.Generic().Input)
|
||||
require.Nil(t, initial.NormalizedPayload.ShellExec())
|
||||
|
||||
structured := map[string]any{
|
||||
"version": float64(1),
|
||||
"resolved_charts": []any{map[string]any{
|
||||
"block_index": float64(0),
|
||||
"labels": []any{"API", "Billing"},
|
||||
"series": []any{map[string]any{
|
||||
"label": "Failed requests",
|
||||
"values": []any{float64(42), float64(27)},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
callToolResult := map[string]any{
|
||||
"content": []any{map[string]any{
|
||||
"type": "text",
|
||||
"text": `{"version":1,"resolved_charts":[]}`,
|
||||
}},
|
||||
"structuredContent": structured,
|
||||
}
|
||||
completed := acpsdk.ToolCallStatus("completed")
|
||||
terminal := a.convertToolCallResultUpdate("session-1", &acpsdk.SessionToolCallUpdate{
|
||||
RawInput: rawInput,
|
||||
RawOutput: map[string]any{"error": nil, "result": callToolResult},
|
||||
SessionUpdate: "tool_call_update",
|
||||
Status: &completed,
|
||||
ToolCallId: acpsdk.ToolCallId("mcp-call-1"),
|
||||
})
|
||||
|
||||
require.NotNil(t, terminal)
|
||||
require.Equal(t, streams.EventTypeToolUpdate, terminal.Type)
|
||||
require.Equal(t, toolStatusComplete, terminal.ToolStatus)
|
||||
require.Equal(t, streams.ToolKindGeneric, terminal.NormalizedPayload.Kind())
|
||||
require.True(t, terminal.NormalizedPayload.IsMCPTool())
|
||||
require.Equal(t, arguments, terminal.NormalizedPayload.Generic().Input)
|
||||
require.Equal(t, callToolResult, terminal.NormalizedPayload.Generic().Output)
|
||||
}
|
||||
|
||||
func TestCodexMCPExecuteFramePreservesServerIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := newTestAdapter()
|
||||
a.agentID = codexAgentID
|
||||
a.normalizer = NewNormalizer(codexAgentID)
|
||||
a.dialect = newACPDialect(codexAgentID)
|
||||
|
||||
event := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{
|
||||
Meta: map[string]any{"is_mcp_tool_call": true},
|
||||
Kind: acpsdk.ToolKind("execute"),
|
||||
RawInput: map[string]any{"server": "other", "tool": "show_rich_output_kandev", "arguments": map[string]any{}},
|
||||
SessionUpdate: "tool_call",
|
||||
Status: acpsdk.ToolCallStatus("in_progress"),
|
||||
ToolCallId: acpsdk.ToolCallId("mcp-call-foreign-server"),
|
||||
})
|
||||
|
||||
require.NotNil(t, event)
|
||||
require.Equal(t, "other/show_rich_output_kandev", event.NormalizedPayload.Generic().Name)
|
||||
}
|
||||
|
||||
func TestCodexExecuteWithoutMCPMarkerRemainsShellExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := newTestAdapter()
|
||||
a.agentID = codexAgentID
|
||||
a.normalizer = NewNormalizer(codexAgentID)
|
||||
a.dialect = newACPDialect(codexAgentID)
|
||||
|
||||
event := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{
|
||||
Kind: acpsdk.ToolKind("execute"),
|
||||
RawInput: map[string]any{"command": "pwd"},
|
||||
SessionUpdate: "tool_call",
|
||||
Status: acpsdk.ToolCallStatus("in_progress"),
|
||||
Title: "Run pwd",
|
||||
ToolCallId: acpsdk.ToolCallId("shell-call-1"),
|
||||
})
|
||||
|
||||
require.NotNil(t, event)
|
||||
require.Equal(t, streams.ToolKindShellExec, event.NormalizedPayload.Kind())
|
||||
require.Equal(t, "pwd", event.NormalizedPayload.ShellExec().Command)
|
||||
}
|
||||
@@ -25,6 +25,13 @@ type acpDialect struct {
|
||||
contextWindow func(map[string]any, []modelInfo, []streams.ConfigOption) (contextWindowSample, bool)
|
||||
normalizePromptUsage func(*streams.PromptUsage, map[string]any) *streams.PromptUsage
|
||||
subagentFrame func(map[string]any, string, any) (subagentFrame, bool)
|
||||
mcpToolCall func(map[string]any, any) (mcpToolCallFrame, bool)
|
||||
mcpToolResult func(any) (any, bool)
|
||||
}
|
||||
|
||||
type mcpToolCallFrame struct {
|
||||
name string
|
||||
arguments map[string]any
|
||||
}
|
||||
|
||||
type dialectConfigChange struct {
|
||||
@@ -62,6 +69,20 @@ func (d acpDialect) parseSubagentFrame(meta map[string]any, title string, rawInp
|
||||
return d.subagentFrame(meta, title, rawInput)
|
||||
}
|
||||
|
||||
func (d acpDialect) parseMCPToolCall(meta map[string]any, rawInput any) (mcpToolCallFrame, bool) {
|
||||
if d.mcpToolCall == nil {
|
||||
return mcpToolCallFrame{}, false
|
||||
}
|
||||
return d.mcpToolCall(meta, rawInput)
|
||||
}
|
||||
|
||||
func (d acpDialect) normalizeMCPToolResult(rawOutput any) (any, bool) {
|
||||
if d.mcpToolResult == nil {
|
||||
return nil, false
|
||||
}
|
||||
return d.mcpToolResult(rawOutput)
|
||||
}
|
||||
|
||||
func (d acpDialect) sessionConfigOptions(
|
||||
meta map[string]any,
|
||||
options []acp.SessionConfigOption,
|
||||
|
||||
@@ -31,6 +31,8 @@ func newCodexACPDialect() acpDialect {
|
||||
return acpDialect{
|
||||
subagentFrame: parseCodexSubagentFrame,
|
||||
normalizePromptUsage: normalizeCodexPromptUsage,
|
||||
mcpToolCall: parseCodexMCPToolCall,
|
||||
mcpToolResult: normalizeCodexMCPToolResult,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +59,44 @@ func normalizeCodexPromptUsage(
|
||||
return usage
|
||||
}
|
||||
|
||||
// parseCodexMCPToolCall recognizes Codex's observed MCP-over-ACP envelope.
|
||||
// Codex reports these as the broad ACP "execute" kind and puts the actual MCP
|
||||
// identity and arguments in rawInput. Require the explicit implementation
|
||||
// marker plus the complete envelope so ordinary execute tools remain shells.
|
||||
func parseCodexMCPToolCall(meta map[string]any, rawInput any) (mcpToolCallFrame, bool) {
|
||||
isMCP, _ := meta["is_mcp_tool_call"].(bool)
|
||||
if !isMCP {
|
||||
return mcpToolCallFrame{}, false
|
||||
}
|
||||
input, ok := rawInput.(map[string]any)
|
||||
if !ok {
|
||||
return mcpToolCallFrame{}, false
|
||||
}
|
||||
server, _ := input["server"].(string)
|
||||
tool, _ := input["tool"].(string)
|
||||
arguments, ok := input["arguments"].(map[string]any)
|
||||
server = strings.TrimSpace(server)
|
||||
tool = strings.TrimSpace(tool)
|
||||
if server == "" || tool == "" || !ok {
|
||||
return mcpToolCallFrame{}, false
|
||||
}
|
||||
return mcpToolCallFrame{name: server + "/" + tool, arguments: arguments}, true
|
||||
}
|
||||
|
||||
// normalizeCodexMCPToolResult removes Codex's transport-only
|
||||
// {error, result} wrapper while retaining the standard MCP CallToolResult.
|
||||
func normalizeCodexMCPToolResult(rawOutput any) (any, bool) {
|
||||
output, ok := rawOutput.(map[string]any)
|
||||
if !ok {
|
||||
return rawOutput, rawOutput != nil
|
||||
}
|
||||
result, exists := output["result"]
|
||||
if !exists {
|
||||
return rawOutput, true
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
// codexSystemErrorMeta reports the explicit thread-status marker emitted by
|
||||
// codex-acp when a prompt failed at the provider boundary. The marker is
|
||||
// implementation metadata, so keep its shape local to the Codex dialect and
|
||||
|
||||
@@ -69,6 +69,10 @@ type NormalizedPayload struct {
|
||||
// gates on ACP `_meta.claudeCode.toolName` — set by the claude-agent-acp
|
||||
// wrapper, not by model tool output. IsActiveMonitor classifies on this.
|
||||
monitor *MonitorPayload
|
||||
// mcpTool is trusted adapter provenance for a recognized MCP execution.
|
||||
// It stays internal because persistence should contain the canonical tool
|
||||
// name, input, and result rather than transport-specific metadata.
|
||||
mcpTool bool
|
||||
}
|
||||
|
||||
// --- Getters for NormalizedPayload ---
|
||||
@@ -85,6 +89,7 @@ func (p *NormalizedPayload) SubagentTask() *SubagentTaskPayload { return p.subag
|
||||
func (p *NormalizedPayload) ShowPlan() *ShowPlanPayload { return p.showPlan }
|
||||
func (p *NormalizedPayload) ManageTodos() *ManageTodosPayload { return p.manageTodos }
|
||||
func (p *NormalizedPayload) Misc() *MiscPayload { return p.misc }
|
||||
func (p *NormalizedPayload) IsMCPTool() bool { return p != nil && p.mcpTool }
|
||||
|
||||
// Snapshot returns a detached copy of the normalized payload. A snapshot owns
|
||||
// every pointer, slice, and JSON-compatible value reachable from the payload so
|
||||
@@ -108,6 +113,7 @@ func (p *NormalizedPayload) Snapshot() *NormalizedPayload {
|
||||
misc: cloneMiscPayload(p.misc),
|
||||
backgroundWork: cloneBackgroundWorkPayload(p.backgroundWork),
|
||||
monitor: cloneMonitorPayload(p.monitor),
|
||||
mcpTool: p.mcpTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +622,13 @@ func NewGeneric(name string, input any) *NormalizedPayload {
|
||||
}
|
||||
}
|
||||
|
||||
// NewMCPTool creates a generic payload with trusted MCP transport provenance.
|
||||
func NewMCPTool(name string, input any) *NormalizedPayload {
|
||||
payload := NewGeneric(name, input)
|
||||
payload.mcpTool = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// NewSubagentTask creates a NormalizedPayload for subagent (Task) tool calls.
|
||||
// Result fields (status, agent id, metrics, …) are filled later from the
|
||||
// completion tool_call_update via the ACP normalizer's EnrichSubagentResult.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
richOutputToolName = "show_rich_output_kandev"
|
||||
richOutputBlockTypeChart = "chart"
|
||||
richOutputBlockTypeFile = "file"
|
||||
richOutputFieldPath = "path"
|
||||
richOutputFieldSessionID = "session_id"
|
||||
maxRichOutputPayloadBytes = 64 * 1024
|
||||
)
|
||||
|
||||
var richOutputInputSchema = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"description": "One compact Kandev-native presentation. Use semantic data only; Kandev owns layout and styling.",
|
||||
"properties": {
|
||||
"version": {"type": "integer", "const": 1, "description": "Rich-output contract version. Must be 1."},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Plain-text presentation title."},
|
||||
"description": {"type": "string", "maxLength": 500, "description": "Optional plain-text context for the full presentation."},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"description": "One to four ordered file, chart, or metrics blocks.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "file", "description": "Workspace file preview block."},
|
||||
"path": {"type": "string", "minLength": 1, "maxLength": 1024, "description": "Workspace-relative file path."},
|
||||
"repo": {"type": "string", "maxLength": 255, "description": "Optional repository discriminator for multi-repository tasks."},
|
||||
"title": {"type": "string", "maxLength": 120, "description": "Optional plain-text file title."},
|
||||
"caption": {"type": "string", "maxLength": 500, "description": "Optional plain-text reason to inspect the file."},
|
||||
"mime_type": {"type": "string", "maxLength": 128, "description": "Optional media type hint."}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "chart", "description": "Native line or bar chart with host-owned axes, tooltip, and legend."},
|
||||
"chart_type": {"type": "string", "enum": ["line", "bar"], "description": "Use line for trends or time series; bar for category comparisons."},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Plain-text chart title."},
|
||||
"summary": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Plain-text nonvisual interpretation of the chart."},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "Inline x-axis labels. Use with inline series, not csv.",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 120}
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"description": "Inline numeric series. Each values array must match labels.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Legend label; include the unit when useful."},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {"type": ["number", "null"]}
|
||||
}
|
||||
},
|
||||
"required": ["label", "values"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"csv": {
|
||||
"type": "object",
|
||||
"description": "Load bounded chart data from one workspace-relative CSV instead of inlining rows.",
|
||||
"properties": {
|
||||
"path": {"type": "string", "minLength": 1, "maxLength": 1024, "description": "Workspace-relative CSV path ending in .csv."},
|
||||
"repo": {"type": "string", "maxLength": 255, "description": "Optional repository discriminator for a multi-repository task."},
|
||||
"x_column": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Exact CSV header used for x-axis category or time labels. Rows render in file order."},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"description": "One to four CSV numeric column mappings.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"column": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Exact CSV header for a numeric column."},
|
||||
"label": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Optional legend label; include the unit when useful. Defaults to column."}
|
||||
},
|
||||
"required": ["column"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["path", "x_column", "series"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "chart_type", "title", "summary"],
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["labels", "series"],
|
||||
"not": {"required": ["csv"]}
|
||||
},
|
||||
{
|
||||
"required": ["csv"],
|
||||
"not": {
|
||||
"anyOf": [
|
||||
{"required": ["labels"]},
|
||||
{"required": ["series"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "metrics", "description": "Compact metric group."},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"value": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"detail": {"type": "string", "maxLength": 500}
|
||||
},
|
||||
"required": ["label", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "items"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{"version":1,"title":"Service failures","blocks":[{"type":"chart","chart_type":"bar","title":"Failures by service","summary":"Failed requests in the latest interval.","labels":["A","B"],"series":[{"label":"Count","values":[42,27]}]}]},
|
||||
{"version":1,"title":"Latency trend","blocks":[{"type":"chart","chart_type":"line","title":"p95 latency","summary":"Latency across recorded samples.","csv":{"path":"reports/latency.csv","x_column":"recorded_at","series":[{"column":"p95_ms","label":"p95 (ms)"}]}}]},
|
||||
{"version":1,"title":"Requests by route","blocks":[{"type":"chart","chart_type":"bar","title":"Route volume","summary":"Request count for each route.","csv":{"path":"reports/routes.csv","x_column":"route","series":[{"column":"requests"}]}}]}
|
||||
],
|
||||
"required": ["version", "title", "blocks"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
|
||||
func (s *Server) registerRichOutputTool() {
|
||||
tool := mcplib.NewToolWithRawSchema(
|
||||
richOutputToolName,
|
||||
`Present one native file preview, metric group, line chart, bar chart, or CSV chart. For an explicit chart, graph, plot, preview, KPI, or metrics request with data, call directly. Do not implement the display as ASCII, SVG, HTML, or with another app. Use Markdown only for small text tables and prose otherwise. Supply semantic data; Kandev owns axes, legends, tooltips, layout, and styling. CSV paths are task-workspace-relative. Label series with units.`,
|
||||
richOutputInputSchema,
|
||||
)
|
||||
tool.Annotations.ReadOnlyHint = mcplib.ToBoolPtr(true)
|
||||
tool.Annotations.DestructiveHint = mcplib.ToBoolPtr(false)
|
||||
s.mcpServer.AddTool(tool, s.wrapHandler(richOutputToolName, s.showRichOutputHandler()))
|
||||
}
|
||||
|
||||
func (s *Server) showRichOutputHandler() server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
if err := validateRichOutput(req.GetArguments()); err != nil {
|
||||
return mcplib.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
snapshot, err := s.resolveRichOutputCSV(ctx, req.GetArguments())
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
if snapshot != nil {
|
||||
return mcplib.NewToolResultStructuredOnly(snapshot), nil
|
||||
}
|
||||
return mcplib.NewToolResultText("Rich output accepted."), nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateRichOutput(args map[string]interface{}) error {
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rich output payload is invalid")
|
||||
}
|
||||
if len(encoded) > maxRichOutputPayloadBytes {
|
||||
return fmt.Errorf("rich output exceeds 64 KiB")
|
||||
}
|
||||
blocks, ok := args["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("rich output blocks are invalid")
|
||||
}
|
||||
for _, rawBlock := range blocks {
|
||||
block, ok := rawBlock.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch block["type"] {
|
||||
case richOutputBlockTypeChart:
|
||||
if err := validateRichOutputChart(block); err != nil {
|
||||
return err
|
||||
}
|
||||
case richOutputBlockTypeFile:
|
||||
filePath, _ := block[richOutputFieldPath].(string)
|
||||
if !isWorkspaceRelativeRichOutputPath(filePath) {
|
||||
return fmt.Errorf("file path must be workspace relative")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isWorkspaceRelativeRichOutputPath(value string) bool {
|
||||
if strings.TrimSpace(value) == "" || strings.ContainsRune(value, '\x00') {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(value, `\`, "/")
|
||||
lower := strings.ToLower(normalized)
|
||||
if strings.HasPrefix(normalized, "/") || strings.Contains(lower, "://") || strings.HasPrefix(lower, "data:") {
|
||||
return false
|
||||
}
|
||||
if len(normalized) >= 2 && isASCIILetter(normalized[0]) && normalized[1] == ':' {
|
||||
return false
|
||||
}
|
||||
for _, segment := range strings.Split(normalized, "/") {
|
||||
if segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return path.Clean(normalized) != "."
|
||||
}
|
||||
|
||||
func isASCIILetter(value byte) bool {
|
||||
return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z'
|
||||
}
|
||||
|
||||
func validateRichOutputChart(block map[string]interface{}) error {
|
||||
if rawCSV, exists := block["csv"]; exists {
|
||||
csvSource, ok := rawCSV.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("chart CSV source is invalid")
|
||||
}
|
||||
filePath, _ := csvSource[richOutputFieldPath].(string)
|
||||
if !isWorkspaceRelativeRichOutputPath(filePath) {
|
||||
return fmt.Errorf("CSV path must be workspace relative")
|
||||
}
|
||||
if !strings.EqualFold(path.Ext(filePath), ".csv") {
|
||||
return fmt.Errorf("CSV path must end in .csv")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
labels, ok := block["labels"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("chart labels are invalid")
|
||||
}
|
||||
series, ok := block["series"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("chart series are invalid")
|
||||
}
|
||||
for _, rawSeries := range series {
|
||||
item, ok := rawSeries.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("chart series are invalid")
|
||||
}
|
||||
values, ok := item["values"].([]interface{})
|
||||
if !ok || len(values) != len(labels) {
|
||||
return fmt.Errorf("chart series values must match labels")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRichOutputCSVBytes = 256 * 1024
|
||||
maxRichOutputCSVRows = 100
|
||||
maxRichOutputChartText = 120
|
||||
)
|
||||
|
||||
type richOutputCSVSeriesInput struct {
|
||||
Column string `json:"column"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type richOutputCSVSourceInput struct {
|
||||
Path string `json:"path"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
XColumn string `json:"x_column"`
|
||||
Series []richOutputCSVSeriesInput `json:"series"`
|
||||
}
|
||||
|
||||
type richOutputCSVBlockInput struct {
|
||||
CSV *richOutputCSVSourceInput `json:"csv,omitempty"`
|
||||
}
|
||||
|
||||
type richOutputCSVPresentationInput struct {
|
||||
Blocks []richOutputCSVBlockInput `json:"blocks"`
|
||||
}
|
||||
|
||||
type richOutputResolvedSeries struct {
|
||||
Label string `json:"label"`
|
||||
Values []*float64 `json:"values"`
|
||||
}
|
||||
|
||||
type richOutputResolvedChart struct {
|
||||
BlockIndex int `json:"block_index"`
|
||||
Labels []string `json:"labels"`
|
||||
Series []richOutputResolvedSeries `json:"series"`
|
||||
}
|
||||
|
||||
type richOutputCSVSnapshot struct {
|
||||
Version int `json:"version"`
|
||||
ResolvedCharts []richOutputResolvedChart `json:"resolved_charts"`
|
||||
}
|
||||
|
||||
func (s *Server) resolveRichOutputCSV(
|
||||
ctx context.Context,
|
||||
args map[string]interface{},
|
||||
) (*richOutputCSVSnapshot, error) {
|
||||
presentation, err := decodeRichOutputCSVInput(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot := &richOutputCSVSnapshot{Version: 1}
|
||||
cache := make(map[string]*streams.FileContentResponse)
|
||||
for blockIndex, block := range presentation.Blocks {
|
||||
if block.CSV == nil {
|
||||
continue
|
||||
}
|
||||
response, readErr := s.readRichOutputCSV(ctx, block.CSV, cache)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
resolved, parseErr := parseRichOutputCSV(*block.CSV, response.Content, blockIndex)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("CSV source %s: %w", block.CSV.Path, parseErr)
|
||||
}
|
||||
snapshot.ResolvedCharts = append(snapshot.ResolvedCharts, resolved)
|
||||
}
|
||||
if len(snapshot.ResolvedCharts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil || len(encoded) > maxRichOutputPayloadBytes {
|
||||
return nil, fmt.Errorf("normalized CSV chart snapshot exceeds 64 KiB")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func decodeRichOutputCSVInput(args map[string]interface{}) (richOutputCSVPresentationInput, error) {
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return richOutputCSVPresentationInput{}, fmt.Errorf("rich output payload is invalid")
|
||||
}
|
||||
var presentation richOutputCSVPresentationInput
|
||||
if err := json.Unmarshal(encoded, &presentation); err != nil {
|
||||
return richOutputCSVPresentationInput{}, fmt.Errorf("rich output payload is invalid")
|
||||
}
|
||||
return presentation, nil
|
||||
}
|
||||
|
||||
func (s *Server) readRichOutputCSV(
|
||||
ctx context.Context,
|
||||
source *richOutputCSVSourceInput,
|
||||
cache map[string]*streams.FileContentResponse,
|
||||
) (*streams.FileContentResponse, error) {
|
||||
cacheKey := source.Repo + "\x00" + source.Path
|
||||
if cached := cache[cacheKey]; cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
payload := map[string]interface{}{richOutputFieldSessionID: s.sessionID, richOutputFieldPath: source.Path}
|
||||
if source.Repo != "" {
|
||||
payload["repo"] = source.Repo
|
||||
}
|
||||
var response streams.FileContentResponse
|
||||
if err := s.backend.RequestPayload(ctx, ws.ActionWorkspaceFileContentGet, payload, &response); err != nil {
|
||||
return nil, fmt.Errorf("could not read CSV source %s: %w", source.Path, err)
|
||||
}
|
||||
if response.Error != "" {
|
||||
return nil, fmt.Errorf("could not read CSV source %s: %s", source.Path, response.Error)
|
||||
}
|
||||
if response.IsBinary || !utf8.ValidString(response.Content) {
|
||||
return nil, fmt.Errorf("CSV source must be UTF-8 text")
|
||||
}
|
||||
if response.Size > maxRichOutputCSVBytes || len(response.Content) > maxRichOutputCSVBytes {
|
||||
return nil, fmt.Errorf("CSV source exceeds 256 KiB")
|
||||
}
|
||||
cache[cacheKey] = &response
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func parseRichOutputCSV(
|
||||
source richOutputCSVSourceInput,
|
||||
content string,
|
||||
blockIndex int,
|
||||
) (richOutputResolvedChart, error) {
|
||||
reader := csv.NewReader(strings.NewReader(content))
|
||||
header, err := reader.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return richOutputResolvedChart{}, fmt.Errorf("CSV source must contain a header row")
|
||||
}
|
||||
if err != nil {
|
||||
return richOutputResolvedChart{}, fmt.Errorf("CSV header is invalid: %w", err)
|
||||
}
|
||||
headerIndexes, err := indexRichOutputCSVHeaders(header)
|
||||
if err != nil {
|
||||
return richOutputResolvedChart{}, err
|
||||
}
|
||||
xIndex, seriesIndexes, err := resolveRichOutputCSVColumns(source, headerIndexes)
|
||||
if err != nil {
|
||||
return richOutputResolvedChart{}, err
|
||||
}
|
||||
reader.FieldsPerRecord = len(header)
|
||||
resolved := newRichOutputResolvedChart(source, blockIndex)
|
||||
for rowNumber := 2; ; rowNumber++ {
|
||||
record, readErr := reader.Read()
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return richOutputResolvedChart{}, fmt.Errorf("CSV row %d is invalid: %w", rowNumber, readErr)
|
||||
}
|
||||
if len(resolved.Labels) == maxRichOutputCSVRows {
|
||||
return richOutputResolvedChart{}, fmt.Errorf("CSV source must contain at most 100 data rows")
|
||||
}
|
||||
if err := appendRichOutputCSVRow(&resolved, source, record, rowNumber, xIndex, seriesIndexes); err != nil {
|
||||
return richOutputResolvedChart{}, err
|
||||
}
|
||||
}
|
||||
if len(resolved.Labels) == 0 {
|
||||
return richOutputResolvedChart{}, fmt.Errorf("CSV source must contain 1 to 100 data rows")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func indexRichOutputCSVHeaders(header []string) (map[string]int, error) {
|
||||
indexes := make(map[string]int, len(header))
|
||||
for index, rawName := range header {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(rawName, "\ufeff"))
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("CSV header %d must not be blank", index+1)
|
||||
}
|
||||
if _, exists := indexes[name]; exists {
|
||||
return nil, fmt.Errorf("CSV header %q is duplicated", name)
|
||||
}
|
||||
indexes[name] = index
|
||||
}
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
func resolveRichOutputCSVColumns(
|
||||
source richOutputCSVSourceInput,
|
||||
headers map[string]int,
|
||||
) (int, []int, error) {
|
||||
xIndex, ok := headers[source.XColumn]
|
||||
if !ok {
|
||||
return 0, nil, fmt.Errorf("CSV column %q was not found", source.XColumn)
|
||||
}
|
||||
seriesIndexes := make([]int, len(source.Series))
|
||||
for index, series := range source.Series {
|
||||
columnIndex, exists := headers[series.Column]
|
||||
if !exists {
|
||||
return 0, nil, fmt.Errorf("CSV column %q was not found", series.Column)
|
||||
}
|
||||
seriesIndexes[index] = columnIndex
|
||||
}
|
||||
return xIndex, seriesIndexes, nil
|
||||
}
|
||||
|
||||
func newRichOutputResolvedChart(
|
||||
source richOutputCSVSourceInput,
|
||||
blockIndex int,
|
||||
) richOutputResolvedChart {
|
||||
resolved := richOutputResolvedChart{BlockIndex: blockIndex}
|
||||
for _, input := range source.Series {
|
||||
label := input.Label
|
||||
if label == "" {
|
||||
label = input.Column
|
||||
}
|
||||
resolved.Series = append(resolved.Series, richOutputResolvedSeries{Label: label})
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func appendRichOutputCSVRow(
|
||||
resolved *richOutputResolvedChart,
|
||||
source richOutputCSVSourceInput,
|
||||
record []string,
|
||||
rowNumber, xIndex int,
|
||||
seriesIndexes []int,
|
||||
) error {
|
||||
label := strings.TrimSpace(record[xIndex])
|
||||
if label == "" {
|
||||
return fmt.Errorf("CSV row %d column %q must not be blank", rowNumber, source.XColumn)
|
||||
}
|
||||
if utf8.RuneCountInString(label) > maxRichOutputChartText {
|
||||
return fmt.Errorf("CSV row %d column %q exceeds 120 characters", rowNumber, source.XColumn)
|
||||
}
|
||||
resolved.Labels = append(resolved.Labels, label)
|
||||
for index, columnIndex := range seriesIndexes {
|
||||
value, err := parseRichOutputCSVNumber(record[columnIndex])
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"CSV row %d column %q must be a finite number or empty",
|
||||
rowNumber,
|
||||
source.Series[index].Column,
|
||||
)
|
||||
}
|
||||
resolved.Series[index].Values = append(resolved.Series[index].Values, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRichOutputCSVNumber(raw string) (*float64, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
number, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, fmt.Errorf("not finite")
|
||||
}
|
||||
return &number, nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRichOutputToolRegistration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
want bool
|
||||
}{
|
||||
{name: "task", mode: ModeTask, want: true},
|
||||
{name: "office", mode: ModeOffice, want: true},
|
||||
{name: "configuration", mode: ModeConfig, want: false},
|
||||
{name: "external", mode: ModeExternal, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, tt.mode)
|
||||
_, got := s.mcpServer.ListTools()["show_rich_output_kandev"]
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichOutputToolAcceptsValidPayload(t *testing.T) {
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
|
||||
result := callTool(t, s, richOutputToolName, validRichOutputArgs())
|
||||
|
||||
require.False(t, result.IsError)
|
||||
require.Len(t, result.Content, 1)
|
||||
}
|
||||
|
||||
func TestRichOutputToolResolvesCSVChartSnapshot(t *testing.T) {
|
||||
csv := "recorded_at,p50_ms,p95_ms\n2026-08-13T10:00:00Z,18.2,29.4\n2026-08-14T10:00:00Z,16.4,\n"
|
||||
backend := &testBackend{response: map[string]interface{}{
|
||||
"path": "reports/latency.csv", "content": csv, "size": len(csv), "is_binary": false,
|
||||
}}
|
||||
s := New(backend, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
|
||||
result := callTool(t, s, richOutputToolName, csvRichOutputArgs())
|
||||
|
||||
require.False(t, result.IsError)
|
||||
assert.Equal(t, ws.ActionWorkspaceFileContentGet, backend.lastAction)
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
"session_id": "session-1", "path": "reports/latency.csv", "repo": "api",
|
||||
}, backend.lastPayload)
|
||||
want := map[string]interface{}{
|
||||
"version": 1,
|
||||
"resolved_charts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_index": 0,
|
||||
"labels": []interface{}{"2026-08-13T10:00:00Z", "2026-08-14T10:00:00Z"},
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"label": "p50", "values": []interface{}{18.2, 16.4}},
|
||||
map[string]interface{}{"label": "p95_ms", "values": []interface{}{29.4, nil}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assertJSONEquivalent(t, want, result.StructuredContent)
|
||||
require.Len(t, result.Content, 1)
|
||||
textContent, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
var fallback map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &fallback))
|
||||
assertJSONEquivalent(t, want, fallback)
|
||||
}
|
||||
|
||||
func TestRichOutputToolRejectsInvalidCSVData(t *testing.T) {
|
||||
tooManyRows := "x,value\n"
|
||||
for i := 0; i < 101; i++ {
|
||||
tooManyRows += fmt.Sprintf("%d,%d\n", i, i)
|
||||
}
|
||||
tooLongLabel := strings.Repeat("x", 121)
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
size int
|
||||
binary bool
|
||||
mutate func(map[string]interface{})
|
||||
want string
|
||||
}{
|
||||
{name: "binary", content: "AAEC", size: 3, binary: true, want: "CSV source must be UTF-8 text"},
|
||||
{name: "oversized", content: "x,value\na,1\n", size: 256*1024 + 1, want: "CSV source exceeds 256 KiB"},
|
||||
{name: "header only", content: "x,value\n", size: 8, want: "CSV source must contain 1 to 100 data rows"},
|
||||
{name: "too many rows", content: tooManyRows, size: len(tooManyRows), want: "CSV source must contain at most 100 data rows"},
|
||||
{name: "duplicate header", content: "x,value,value\na,1,2\n", size: 20, want: `CSV header "value" is duplicated`},
|
||||
{name: "missing x column", content: "when,value\na,1\n", size: 15, want: `CSV column "x" was not found`},
|
||||
{name: "missing series column", content: "x,other\na,1\n", size: 12, want: `CSV column "value" was not found`},
|
||||
{name: "blank x value", content: "x,value\n,1\n", size: 11, want: `CSV row 2 column "x" must not be blank`},
|
||||
{name: "long x value", content: "x,value\n" + tooLongLabel + ",1\n", size: 132, want: `CSV row 2 column "x" exceeds 120 characters`},
|
||||
{name: "bad numeric value", content: "x,value\na,fast\n", size: 15, want: `CSV row 2 column "value" must be a finite number or empty`},
|
||||
{name: "uneven row", content: "x,value\na,1,extra\n", size: 18, want: "CSV row 2"},
|
||||
{
|
||||
name: "unsafe path", content: "x,value\na,1\n", size: 12, want: "CSV path must be workspace relative",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
csvChartSource(args)["path"] = "../report.csv"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non CSV path", content: "x,value\na,1\n", size: 12, want: "CSV path must end in .csv",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
csvChartSource(args)["path"] = "reports/report.txt"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backend := &testBackend{response: map[string]interface{}{
|
||||
"path": "reports/latency.csv", "content": tt.content, "size": tt.size, "is_binary": tt.binary,
|
||||
}}
|
||||
s := New(backend, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
args := csvRichOutputArgs()
|
||||
source := csvChartSource(args)
|
||||
source["x_column"] = "x"
|
||||
source["series"] = []interface{}{map[string]interface{}{"column": "value"}}
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(args)
|
||||
}
|
||||
|
||||
result := callTool(t, s, richOutputToolName, args)
|
||||
|
||||
require.True(t, result.IsError)
|
||||
content, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, content.Text, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichOutputToolReportsCSVReadFailure(t *testing.T) {
|
||||
backend := &testBackend{err: fmt.Errorf("workspace offline")}
|
||||
s := New(backend, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
|
||||
result := callTool(t, s, richOutputToolName, csvRichOutputArgs())
|
||||
|
||||
require.True(t, result.IsError)
|
||||
content, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, content.Text, "could not read CSV source reports/latency.csv")
|
||||
}
|
||||
|
||||
func TestRichOutputToolDescriptionStaysFocusedAndDiscoverable(t *testing.T) {
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
tool := s.mcpServer.ListTools()[richOutputToolName].Tool
|
||||
|
||||
for _, phrase := range []string{"file preview", "line chart", "bar chart", "CSV chart", "graph", "plot", "KPI", "metrics"} {
|
||||
assert.Contains(t, tool.Description, phrase)
|
||||
}
|
||||
for _, phrase := range []string{
|
||||
"explicit chart, graph, plot, preview, KPI, or metrics request with data, call directly",
|
||||
"Do not implement the display as ASCII, SVG, HTML, or with another app",
|
||||
"Kandev owns axes, legends, tooltips, layout, and styling",
|
||||
"task-workspace-relative",
|
||||
"Markdown only for small text tables",
|
||||
"Label series with units",
|
||||
} {
|
||||
assert.Contains(t, tool.Description, phrase)
|
||||
}
|
||||
assert.NotContains(t, tool.Description, "Inline chart recipe")
|
||||
assert.NotContains(t, tool.Description, "CSV chart recipe")
|
||||
assert.NotContains(t, tool.Description, `"labels":["A","B"]`)
|
||||
assert.NotContains(t, tool.Description, `"x_column":"recorded_at"`)
|
||||
|
||||
encoded := tool.RawInputSchema
|
||||
require.NotEmpty(t, encoded)
|
||||
for _, phrase := range []string{"workspace-relative CSV", "x-axis", "numeric column"} {
|
||||
assert.Contains(t, string(encoded), phrase)
|
||||
}
|
||||
assert.Contains(t, string(encoded), `"examples"`)
|
||||
assert.Contains(t, string(encoded), `"chart_type":"bar"`)
|
||||
assert.Contains(t, string(encoded), `"chart_type":"line"`)
|
||||
assert.Contains(t, string(encoded), `"labels":["A","B"]`)
|
||||
assert.Contains(t, string(encoded), `"series":[{"label":"Count","values":[42,27]}]`)
|
||||
require.NotNil(t, tool.Annotations.ReadOnlyHint)
|
||||
assert.True(t, *tool.Annotations.ReadOnlyHint)
|
||||
require.NotNil(t, tool.Annotations.DestructiveHint)
|
||||
assert.False(t, *tool.Annotations.DestructiveHint)
|
||||
}
|
||||
|
||||
func TestRichOutputToolRejectsMismatchedChartSeries(t *testing.T) {
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
args := validRichOutputArgs()
|
||||
chart := args["blocks"].([]interface{})[1].(map[string]interface{})
|
||||
series := chart["series"].([]interface{})[0].(map[string]interface{})
|
||||
series["values"] = []interface{}{18.2}
|
||||
|
||||
result := callTool(t, s, richOutputToolName, args)
|
||||
|
||||
require.True(t, result.IsError)
|
||||
content, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, content.Text, "chart series values must match labels")
|
||||
}
|
||||
|
||||
func TestRichOutputToolRejectsUnsafeFilePaths(t *testing.T) {
|
||||
tests := []string{
|
||||
"/etc/passwd",
|
||||
"../secrets.txt",
|
||||
"reports/../../secrets.txt",
|
||||
"https://example.com/report.json",
|
||||
"data:text/plain,secret",
|
||||
`C:\\Users\\person\\secret.txt`,
|
||||
`\\\\server\\share\\secret.txt`,
|
||||
}
|
||||
|
||||
for _, filePath := range tests {
|
||||
t.Run(filePath, func(t *testing.T) {
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
args := validRichOutputArgs()
|
||||
file := args["blocks"].([]interface{})[2].(map[string]interface{})
|
||||
file["path"] = filePath
|
||||
|
||||
result := callTool(t, s, richOutputToolName, args)
|
||||
|
||||
require.True(t, result.IsError)
|
||||
content, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, content.Text, "file path must be workspace relative")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichOutputToolRejectsOversizedPayload(t *testing.T) {
|
||||
labels := make([]interface{}, 100)
|
||||
values := make([]interface{}, 100)
|
||||
for i := range labels {
|
||||
labels[i] = strings.Repeat("a", 120)
|
||||
values[i] = 123456789012345.5
|
||||
}
|
||||
blocks := make([]interface{}, 4)
|
||||
for i := range blocks {
|
||||
series := make([]interface{}, 4)
|
||||
for j := range series {
|
||||
series[j] = map[string]interface{}{
|
||||
"label": strings.Repeat("s", 120),
|
||||
"values": values,
|
||||
}
|
||||
}
|
||||
blocks[i] = map[string]interface{}{
|
||||
"type": "chart",
|
||||
"chart_type": "bar",
|
||||
"title": "Large chart",
|
||||
"summary": "Bounded presentation",
|
||||
"labels": labels,
|
||||
"series": series,
|
||||
}
|
||||
}
|
||||
args := map[string]interface{}{"version": 1, "title": "Too large", "blocks": blocks}
|
||||
encoded, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(encoded), 64*1024)
|
||||
s := New(&testBackend{}, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
|
||||
result := callTool(t, s, richOutputToolName, args)
|
||||
|
||||
require.True(t, result.IsError)
|
||||
content, ok := result.Content[0].(mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, content.Text, "rich output exceeds 64 KiB")
|
||||
}
|
||||
|
||||
func TestRichOutputToolRejectsSchemaViolations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "unsupported version",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
args["version"] = 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown top-level field",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
args["theme"] = "neon"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no blocks",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
args["blocks"] = []interface{}{}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown block type",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
args["blocks"] = []interface{}{map[string]interface{}{"type": "table"}}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many metrics",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
items := make([]interface{}, 7)
|
||||
for i := range items {
|
||||
items[i] = map[string]interface{}{"label": "Metric", "value": "1"}
|
||||
}
|
||||
args["blocks"] = []interface{}{map[string]interface{}{"type": "metrics", "items": items}}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many chart labels",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
labels := make([]interface{}, 101)
|
||||
values := make([]interface{}, 101)
|
||||
for i := range labels {
|
||||
labels[i] = "run"
|
||||
values[i] = i
|
||||
}
|
||||
args["blocks"] = []interface{}{map[string]interface{}{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "Runs",
|
||||
"summary": "Run history",
|
||||
"labels": labels,
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"label": "Seconds", "values": values},
|
||||
},
|
||||
}}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CSV chart with stray inline labels",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
args["blocks"] = csvRichOutputArgs()["blocks"]
|
||||
chart := args["blocks"].([]interface{})[0].(map[string]interface{})
|
||||
chart["labels"] = []interface{}{"run-1"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inline chart with stray CSV source",
|
||||
mutate: func(args map[string]interface{}) {
|
||||
chart := args["blocks"].([]interface{})[1].(map[string]interface{})
|
||||
chart["csv"] = csvChartSource(csvRichOutputArgs())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backend := &testBackend{}
|
||||
s := New(backend, "session-1", "task-1", 10005, newTestLogger(t), "", false, ModeTask)
|
||||
args := validRichOutputArgs()
|
||||
tt.mutate(args)
|
||||
|
||||
result := callTool(t, s, richOutputToolName, args)
|
||||
|
||||
require.True(t, result.IsError)
|
||||
assert.Empty(t, backend.lastAction)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validRichOutputArgs() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"version": 1,
|
||||
"title": "Build health",
|
||||
"description": "Latest local verification",
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "metrics",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"label": "Passed", "value": "38"},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "Runtime by run",
|
||||
"summary": "Runtime fell across the last three runs.",
|
||||
"labels": []interface{}{"1", "2", "3"},
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"label": "Seconds", "values": []interface{}{18.2, 16.4, nil}},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "file",
|
||||
"path": "reports/build.json",
|
||||
"repo": "kandev",
|
||||
"title": "Raw report",
|
||||
"caption": "Generated by verification",
|
||||
"mime_type": "application/json",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func csvRichOutputArgs() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"version": 1,
|
||||
"title": "API latency",
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "Latency over time",
|
||||
"summary": "p50 and p95 latency from the workspace report.",
|
||||
"csv": map[string]interface{}{
|
||||
"path": "reports/latency.csv",
|
||||
"repo": "api",
|
||||
"x_column": "recorded_at",
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"column": "p50_ms", "label": "p50"},
|
||||
map[string]interface{}{"column": "p95_ms"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func csvChartSource(args map[string]interface{}) map[string]interface{} {
|
||||
chart := args["blocks"].([]interface{})[0].(map[string]interface{})
|
||||
return chart["csv"].(map[string]interface{})
|
||||
}
|
||||
|
||||
func assertJSONEquivalent(t *testing.T, want, got interface{}) {
|
||||
t.Helper()
|
||||
normalize := func(value interface{}) interface{} {
|
||||
encoded, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
var normalized interface{}
|
||||
require.NoError(t, json.Unmarshal(encoded, &normalized))
|
||||
return normalized
|
||||
}
|
||||
assert.Equal(t, normalize(want), normalize(got))
|
||||
}
|
||||
@@ -909,6 +909,7 @@ func (s *Server) profileToolGroups() []profileToolGroup {
|
||||
{name: "user-question", enabled: capabilityEnabled(mcpprofile.CapabilityUserQuestion), register: func(s *Server) { s.registerInteractionTools() }},
|
||||
{name: "parent-question", enabled: andProfilePredicates(kanban, capabilityEnabled(mcpprofile.CapabilityParentQuestion)), register: func(s *Server) { s.registerParentQuestionTool() }},
|
||||
{name: "plan", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerPlanTools() }},
|
||||
{name: "rich-output", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerRichOutputTool() }},
|
||||
{name: "walkthrough", enabled: kanban, register: func(s *Server) { s.registerWalkthroughTools() }},
|
||||
{name: "review", enabled: kanban, register: func(s *Server) { s.registerReviewTools() }},
|
||||
{name: "related-tasks", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerRelatedTasksTool() }},
|
||||
|
||||
@@ -709,7 +709,7 @@ drained:
|
||||
// as in TestServerModeTask_ToolCount and
|
||||
// TestRegisterTools_LoggedCountMatchesRegisteredTools (list_task_sessions_test.go),
|
||||
// which pin the per-mode registration rather than this SetProviders rebuild.
|
||||
require.Len(t, tools, 35, "final registry should contain the complete GitLab-only task tool set")
|
||||
require.Len(t, tools, 36, "final registry should contain the complete GitLab-only task tool set")
|
||||
assert.Contains(t, tools, "get_task_mr_automation_kandev")
|
||||
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
|
||||
}
|
||||
@@ -878,7 +878,7 @@ func TestServerModeTask_ToolCount(t *testing.T) {
|
||||
// 1 add_workspace_sources + 1 update_repository_base_branch +
|
||||
// 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough +
|
||||
// 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle
|
||||
// + 2 task-dependency (add/remove) = 37.
|
||||
// + 2 task-dependency (add/remove) + 1 rich-output = 38.
|
||||
// Task-document tools (list/get/write) are office-only.
|
||||
assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode")
|
||||
assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode")
|
||||
@@ -888,7 +888,8 @@ func TestServerModeTask_ToolCount(t *testing.T) {
|
||||
assert.Contains(t, tools, "list_task_sessions_kandev", "session discovery must be registered in task mode")
|
||||
assert.Contains(t, tools, "add_task_dependency_kandev", "dependency edges must be manageable in task mode")
|
||||
assert.Contains(t, tools, "remove_task_dependency_kandev")
|
||||
assert.Equal(t, 37, len(tools))
|
||||
assert.Contains(t, tools, "show_rich_output_kandev", "native rich output must be registered in task mode")
|
||||
assert.Equal(t, 38, len(tools))
|
||||
}
|
||||
|
||||
func TestServerStepCompleteTool_TaskOnlyAndDiscoverable(t *testing.T) {
|
||||
@@ -958,6 +959,7 @@ func TestServerModeOffice_RegistersCorrectTools(t *testing.T) {
|
||||
|
||||
// Office mode should have interaction tools
|
||||
assert.Contains(t, tools, "ask_user_question_kandev")
|
||||
assert.Contains(t, tools, "show_rich_output_kandev")
|
||||
|
||||
// delegate_task_kandev was removed from ModeOffice when the
|
||||
// agentctl CLI started covering task creation/delegation via
|
||||
@@ -994,10 +996,10 @@ func TestServerModeOffice_ToolCount(t *testing.T) {
|
||||
|
||||
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeOffice)
|
||||
tools := getRegisteredToolNames(s)
|
||||
// 4 plan + 1 interaction + 1 related-tasks + 3 task-documents = 9
|
||||
// 4 plan + 1 interaction + 1 related-tasks + 3 task-documents + 1 rich-output = 10
|
||||
// (delegate_task_kandev retired in favour of `agentctl kandev task create …`).
|
||||
assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev is kanban-task-only; office mode advances tasks via its own approval surface")
|
||||
assert.Equal(t, 9, len(tools))
|
||||
assert.Equal(t, 10, len(tools))
|
||||
}
|
||||
|
||||
func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
|
||||
@@ -1014,8 +1016,8 @@ func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
|
||||
// delegate_task_kandev was retired from ModeOffice (now lives in
|
||||
// the agentctl CLI as `agentctl kandev task create --parent …`).
|
||||
assert.NotContains(t, tools, "delegate_task_kandev")
|
||||
// 4 plan + 1 related-tasks + 3 task-documents = 8 (no ask_user_question, no delegate)
|
||||
assert.Equal(t, 8, len(tools))
|
||||
// 4 plan + 1 related-tasks + 3 task-documents + 1 rich-output = 9 (no ask_user_question, no delegate)
|
||||
assert.Equal(t, 9, len(tools))
|
||||
}
|
||||
|
||||
func TestServerModeConstants(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
promptcfg "github.com/kandev/kandev/config/prompts"
|
||||
"github.com/kandev/kandev/internal/sysprompt"
|
||||
@@ -172,6 +173,47 @@ func TestTaskControlDocs_OmittedFromRestrictedModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichOutputDocs_MakeExplicitAndCSVUseDiscoverable(t *testing.T) {
|
||||
inlineRecipe := `{"type":"chart","chart_type":"bar","title":"T","summary":"S","labels":["A","B"],"series":[{"label":"Count","values":[42,27]}]}`
|
||||
csvRecipe := `{"type":"chart","chart_type":"line","title":"T","summary":"S","csv":{"path":"reports/latency.csv","x_column":"recorded_at","series":[{"column":"p95_ms","label":"p95 (ms)"}]}}`
|
||||
metricsRecipe := `{"type":"metrics","items":[{"label":"Passed","value":"38"}]}`
|
||||
|
||||
for name, prompt := range map[string]string{
|
||||
"task": sysprompt.KandevContext(),
|
||||
"office": sysprompt.OfficeContext(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var richOutputLine string
|
||||
for _, line := range strings.Split(prompt, "\n") {
|
||||
if strings.HasPrefix(line, "- show_rich_output_kandev:") {
|
||||
richOutputLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, richOutputLine)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(richOutputLine), 750)
|
||||
for _, phrase := range []string{
|
||||
"When user asks for chart/graph/plot/file preview/KPI/metrics with data: call now",
|
||||
"Do not implement the display as ASCII/SVG/HTML or with another app",
|
||||
"version=1,title,blocks (1-4)",
|
||||
"x_column",
|
||||
"workspace-relative",
|
||||
"Label series with units",
|
||||
"small text table: Markdown",
|
||||
"Inline:",
|
||||
"CSV line:",
|
||||
"Metrics:",
|
||||
"Kandev owns axes/legends/tooltips/layout",
|
||||
} {
|
||||
assert.Contains(t, richOutputLine, phrase)
|
||||
}
|
||||
assert.Equal(t, 1, strings.Count(richOutputLine, inlineRecipe))
|
||||
assert.Equal(t, 1, strings.Count(richOutputLine, csvRecipe))
|
||||
assert.Equal(t, 1, strings.Count(richOutputLine, metricsRecipe))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyspromptToolNames_MatchMCPConfigMode verifies that every `<name>_kandev`
|
||||
// tool referenced in ConfigContext is registered by an MCP server in ModeConfig.
|
||||
func TestSyspromptToolNames_MatchMCPConfigMode(t *testing.T) {
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestCoreToolDescriptionsStayFocused(t *testing.T) {
|
||||
"add_branch_to_task_kandev": 650,
|
||||
"step_complete_kandev": 650,
|
||||
"ask_user_question_kandev": 600,
|
||||
"show_rich_output_kandev": 650,
|
||||
"show_walkthrough_kandev": 650,
|
||||
"publish_review_findings_kandev": 650,
|
||||
"update_repository_base_branch_kandev": 500,
|
||||
|
||||
@@ -74,7 +74,7 @@ function MessageList({
|
||||
taskId: string;
|
||||
sessionId: string | null;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
activeTurnId: string | null;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconActivity, IconGitBranch } from "@tabler/icons-react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
|
||||
import type { AppearanceState } from "./appearance-settings-state";
|
||||
import { AppStatusBarSettingsCard } from "./app-status-bar-settings-card";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SystemMetricsSettingsCard } from "./system-metrics-settings-card";
|
||||
|
||||
function ChangesPanelLayoutCard({
|
||||
value,
|
||||
isDirty,
|
||||
onChange,
|
||||
}: {
|
||||
value: "flat" | "tree";
|
||||
isDirty: boolean;
|
||||
onChange: (value: "flat" | "tree") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsCard
|
||||
isDirty={isDirty}
|
||||
discoveryTargetId={GENERAL_SETTINGS_TARGETS.changesPanelLayout}
|
||||
data-testid="changes-panel-layout-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("settings:changesPanelLayout")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="changes-panel-layout">{t("settings:fileListView")}</Label>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as "flat" | "tree")}>
|
||||
<SelectTrigger
|
||||
id="changes-panel-layout"
|
||||
data-testid="changes-panel-layout-select"
|
||||
data-settings-dirty={isDirty}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flat">{t("settings:flatList")}</SelectItem>
|
||||
<SelectItem value="tree">{t("settings:tree")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:displayChangedFilesAsAFlat")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceAccountSections({
|
||||
draft,
|
||||
saved,
|
||||
updateDraft,
|
||||
}: {
|
||||
draft: AppearanceState;
|
||||
saved: AppearanceState;
|
||||
updateDraft: (patch: Partial<AppearanceState>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
icon={<IconActivity className="h-5 w-5" />}
|
||||
title={t("settings:statusBar")}
|
||||
description={t("settings:configureStatusBarVisibility")}
|
||||
>
|
||||
<AppStatusBarSettingsCard
|
||||
enabled={draft.appStatusBarEnabled}
|
||||
isDirty={draft.appStatusBarEnabled !== saved.appStatusBarEnabled}
|
||||
onChange={(appStatusBarEnabled) => updateDraft({ appStatusBarEnabled })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title={t("settings:changesPanel")}
|
||||
description={t("settings:customizeHowChangedFilesAreDisplayed")}
|
||||
>
|
||||
<ChangesPanelLayoutCard
|
||||
value={draft.changesPanelLayout}
|
||||
isDirty={draft.changesPanelLayout !== saved.changesPanelLayout}
|
||||
onChange={(changesPanelLayout) => updateDraft({ changesPanelLayout })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconActivity className="h-5 w-5" />}
|
||||
title={t("settings:resourceMetrics")}
|
||||
description={t("settings:configureBackendAndExecutionResourceSampling")}
|
||||
>
|
||||
<SystemMetricsSettingsCard
|
||||
showInTopbar={draft.showMetrics}
|
||||
isShowInTopbarDirty={draft.showMetrics !== saved.showMetrics}
|
||||
onShowInTopbarChange={(showMetrics) => updateDraft({ showMetrics })}
|
||||
simplified={draft.simplifiedMetrics}
|
||||
isSimplifiedDirty={draft.simplifiedMetrics !== saved.simplifiedMetrics}
|
||||
onSimplifiedChange={(simplifiedMetrics) => updateDraft({ simplifiedMetrics })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { UserSettingsUpdatePayload } from "@/lib/types/http";
|
||||
export type AppearanceState = {
|
||||
theme: Theme;
|
||||
settingsMenuMode: SettingsMenuMode;
|
||||
richOutputAnimationsEnabled: boolean;
|
||||
startupPage: UserSettingsState["startupPage"];
|
||||
changesPanelLayout: UserSettingsState["changesPanelLayout"];
|
||||
appStatusBarEnabled: boolean;
|
||||
@@ -16,6 +17,7 @@ export type AppearanceState = {
|
||||
export function createAppearanceSavedState(
|
||||
theme: Theme,
|
||||
settingsMenuMode: SettingsMenuMode,
|
||||
richOutputAnimationsEnabled: boolean,
|
||||
userSettings: Pick<
|
||||
UserSettingsState,
|
||||
"appStatusBarEnabled" | "changesPanelLayout" | "startupPage" | "systemMetricsDisplay"
|
||||
@@ -26,6 +28,7 @@ export function createAppearanceSavedState(
|
||||
appStatusBarEnabled: userSettings.appStatusBarEnabled,
|
||||
// Per-device, but drafted and saved with account settings under one control.
|
||||
settingsMenuMode,
|
||||
richOutputAnimationsEnabled,
|
||||
changesPanelLayout: userSettings.changesPanelLayout,
|
||||
startupPage: userSettings.startupPage,
|
||||
showMetrics: userSettings.systemMetricsDisplay.showInTopbar,
|
||||
@@ -73,6 +76,7 @@ export function rebaseAppearanceDraft(
|
||||
return {
|
||||
theme: rebase("theme"),
|
||||
settingsMenuMode: rebase("settingsMenuMode"),
|
||||
richOutputAnimationsEnabled: rebase("richOutputAnimationsEnabled"),
|
||||
startupPage: rebase("startupPage"),
|
||||
changesPanelLayout: rebase("changesPanelLayout"),
|
||||
appStatusBarEnabled: rebase("appStatusBarEnabled"),
|
||||
@@ -85,6 +89,7 @@ export function appearanceRevision(state: AppearanceState): string {
|
||||
return JSON.stringify([
|
||||
state.theme,
|
||||
state.settingsMenuMode,
|
||||
state.richOutputAnimationsEnabled,
|
||||
state.startupPage,
|
||||
state.changesPanelLayout,
|
||||
state.appStatusBarEnabled,
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AppearanceSettings } from "./general-settings";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() }));
|
||||
const SHOW_STATUS_BAR_LABEL = "Show status bar";
|
||||
const ANIMATE_RICH_OUTPUT_CHARTS_LABEL = "Animate rich-output charts";
|
||||
const SAVE_CHANGES_LABEL = "Save changes";
|
||||
const CHECKED_STATE = "checked";
|
||||
const DATA_STATE_ATTRIBUTE = "data-state";
|
||||
const DATA_SETTINGS_DIRTY_ATTRIBUTE = "data-settings-dirty";
|
||||
const INITIAL_REVISION = 1;
|
||||
const OLDER_LIVE_REVISION = 2;
|
||||
const SAVED_REVISION = 3;
|
||||
@@ -23,6 +25,9 @@ const storeMocks = vi.hoisted(() => ({
|
||||
previewSettingsMenuMode: vi.fn(),
|
||||
commitSettingsMenuMode: vi.fn(),
|
||||
restoreSettingsMenuMode: vi.fn(),
|
||||
previewRichOutputAnimations: vi.fn(),
|
||||
commitRichOutputAnimations: vi.fn(),
|
||||
restoreRichOutputAnimations: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -183,7 +188,7 @@ async function verifyEditDuringSaveSurvivesItsLiveUpdate() {
|
||||
resolveSave(appearanceSettingsResponse({ app_status_bar_enabled: false }));
|
||||
await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce());
|
||||
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||
expect(toggle.getAttribute("data-settings-dirty")).toBe("true");
|
||||
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
||||
}
|
||||
|
||||
async function verifyNewerSaveResponseWinsOverOlderLiveUpdate() {
|
||||
@@ -241,21 +246,58 @@ beforeEach(() => {
|
||||
storeMocks.previewSettingsMenuMode.mockReset();
|
||||
storeMocks.commitSettingsMenuMode.mockReset();
|
||||
storeMocks.restoreSettingsMenuMode.mockReset();
|
||||
storeMocks.previewRichOutputAnimations.mockReset();
|
||||
storeMocks.commitRichOutputAnimations.mockReset();
|
||||
storeMocks.restoreRichOutputAnimations.mockReset();
|
||||
storeMocks.state = {
|
||||
userSettings: {
|
||||
...defaultSettingsState.userSettings,
|
||||
appStatusBarEnabled: true,
|
||||
},
|
||||
settingsMenu: { savedMode: "flat" },
|
||||
richOutputMotion: { enabled: true, savedEnabled: true },
|
||||
setUserSettings: storeMocks.setUserSettings,
|
||||
previewSettingsMenuMode: storeMocks.previewSettingsMenuMode,
|
||||
commitSettingsMenuMode: storeMocks.commitSettingsMenuMode,
|
||||
restoreSettingsMenuMode: storeMocks.restoreSettingsMenuMode,
|
||||
previewRichOutputAnimations: storeMocks.previewRichOutputAnimations,
|
||||
commitRichOutputAnimations: storeMocks.commitRichOutputAnimations,
|
||||
restoreRichOutputAnimations: storeMocks.restoreRichOutputAnimations,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("AppearanceSettings rich-output motion preference", () => {
|
||||
it("previews and saves chart motion locally without a user-settings request", async () => {
|
||||
renderAppearance();
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: ANIMATE_RICH_OUTPUT_CHARTS_LABEL });
|
||||
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(storeMocks.previewRichOutputAnimations).toHaveBeenCalledWith(false);
|
||||
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
||||
|
||||
await waitFor(() => expect(storeMocks.commitRichOutputAnimations).toHaveBeenCalledWith(false));
|
||||
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
||||
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the saved chart motion preference through Reset", async () => {
|
||||
renderAppearance();
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: ANIMATE_RICH_OUTPUT_CHARTS_LABEL });
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
|
||||
|
||||
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
|
||||
expect(storeMocks.restoreRichOutputAnimations).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppearanceSettings status bar preference", () => {
|
||||
it("saves local-only Appearance changes without a user-settings request", async () => {
|
||||
renderAppearance();
|
||||
@@ -288,7 +330,7 @@ describe("AppearanceSettings status bar preference", () => {
|
||||
|
||||
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
||||
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
||||
expect(toggle.getAttribute("data-settings-dirty")).toBe("true");
|
||||
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
||||
|
||||
@@ -328,7 +370,7 @@ describe("AppearanceSettings status bar preference", () => {
|
||||
|
||||
expect(await screen.findByText("Couldn't save")).toBeTruthy();
|
||||
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe("unchecked");
|
||||
expect(toggle.getAttribute("data-settings-dirty")).toBe("true");
|
||||
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
||||
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(storeMocks.state.userSettings as { appStatusBarEnabled: boolean }).appStatusBarEnabled,
|
||||
|
||||
@@ -3,12 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@/components/theme/app-theme";
|
||||
import {
|
||||
IconActivity,
|
||||
IconBinaryTree,
|
||||
IconCommand,
|
||||
IconPalette,
|
||||
IconKeyboard,
|
||||
IconGitBranch,
|
||||
IconArchive,
|
||||
IconArrowBackUp,
|
||||
IconListCheck,
|
||||
@@ -24,7 +22,6 @@ import { SettingsCardHeader } from "@/components/settings/settings-card-header";
|
||||
import { settingsControlClassName } from "@/components/settings/settings-control";
|
||||
import { SettingsPageHeader } from "@/components/settings/settings-typography";
|
||||
import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card";
|
||||
import { SystemMetricsSettingsCard } from "@/components/settings/system-metrics-settings-card";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { updateUserSettings } from "@/lib/api";
|
||||
import type { Theme } from "@/lib/settings/types";
|
||||
@@ -44,8 +41,9 @@ import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
|
||||
import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card";
|
||||
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
|
||||
import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings";
|
||||
import { AppStatusBarSettingsCard } from "@/components/settings/app-status-bar-settings-card";
|
||||
import { SettingsMenuModeCard } from "@/components/settings/settings-menu-mode-card";
|
||||
import { RichOutputMotionSettingsCard } from "@/components/settings/rich-output-motion-settings-card";
|
||||
import { AppearanceAccountSections } from "@/components/settings/appearance-account-sections";
|
||||
import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
|
||||
@@ -138,51 +136,6 @@ function ChatSubmitKeyCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesPanelLayoutCard({
|
||||
value,
|
||||
isDirty,
|
||||
onChange,
|
||||
}: {
|
||||
value: "flat" | "tree";
|
||||
isDirty: boolean;
|
||||
onChange: (value: "flat" | "tree") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsCard
|
||||
isDirty={isDirty}
|
||||
discoveryTargetId={GENERAL_SETTINGS_TARGETS.changesPanelLayout}
|
||||
data-testid="changes-panel-layout-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("settings:changesPanelLayout")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="changes-panel-layout">{t("settings:fileListView")}</Label>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as "flat" | "tree")}>
|
||||
<SelectTrigger
|
||||
id="changes-panel-layout"
|
||||
data-testid="changes-panel-layout-select"
|
||||
data-settings-dirty={isDirty}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flat">{t("settings:flatList")}</SelectItem>
|
||||
<SelectItem value="tree">{t("settings:tree")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:displayChangedFilesAsAFlat")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StartupPageSettingsSection({
|
||||
value,
|
||||
isDirty,
|
||||
@@ -237,12 +190,18 @@ function SettingsMenuModeSection({
|
||||
|
||||
function AppearanceThemeSection({
|
||||
theme,
|
||||
isDirty,
|
||||
onChange,
|
||||
isThemeDirty,
|
||||
onThemeChange,
|
||||
richOutputAnimationsEnabled,
|
||||
isRichOutputMotionDirty,
|
||||
onRichOutputMotionChange,
|
||||
}: {
|
||||
theme: Theme;
|
||||
isDirty: boolean;
|
||||
onChange: (theme: Theme) => void;
|
||||
isThemeDirty: boolean;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
richOutputAnimationsEnabled: boolean;
|
||||
isRichOutputMotionDirty: boolean;
|
||||
onRichOutputMotionChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -251,35 +210,18 @@ function AppearanceThemeSection({
|
||||
title={t("settings:appearance")}
|
||||
description={t("settings:customizeHowTheApplicationLooks")}
|
||||
>
|
||||
<ThemeSettingsCard theme={theme} isDirty={isDirty} onChange={onChange} />
|
||||
<div className="space-y-4">
|
||||
<ThemeSettingsCard theme={theme} isDirty={isThemeDirty} onChange={onThemeChange} />
|
||||
<RichOutputMotionSettingsCard
|
||||
enabled={richOutputAnimationsEnabled}
|
||||
isDirty={isRichOutputMotionDirty}
|
||||
onChange={onRichOutputMotionChange}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function AppStatusBarSettingsSection({
|
||||
enabled,
|
||||
isDirty,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isDirty: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
icon={<IconActivity className="h-5 w-5" />}
|
||||
title={t("settings:statusBar")}
|
||||
description={t("settings:configureStatusBarVisibility")}
|
||||
>
|
||||
<AppStatusBarSettingsCard enabled={enabled} isDirty={isDirty} onChange={onChange} />
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskActionsSettings() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -352,6 +294,9 @@ function useAppearanceSaveContributor({
|
||||
const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode);
|
||||
const commitMenuMode = useAppStore((state) => state.commitSettingsMenuMode);
|
||||
const restoreMenuMode = useAppStore((state) => state.restoreSettingsMenuMode);
|
||||
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
|
||||
const commitRichOutputAnimations = useAppStore((state) => state.commitRichOutputAnimations);
|
||||
const restoreRichOutputAnimations = useAppStore((state) => state.restoreRichOutputAnimations);
|
||||
const revision = appearanceRevision(draft);
|
||||
|
||||
useSettingsSaveContributor({
|
||||
@@ -369,6 +314,7 @@ function useAppearanceSaveContributor({
|
||||
const response = Object.keys(patch).length > 0 ? await updateUserSettings(patch) : null;
|
||||
commitTheme(submitted.theme);
|
||||
commitMenuMode(submitted.settingsMenuMode);
|
||||
commitRichOutputAnimations(submitted.richOutputAnimationsEnabled);
|
||||
// A draft edited while the save was in flight keeps its preview: what was
|
||||
// submitted is now persisted, but the screen should still show what the
|
||||
// user is currently looking at.
|
||||
@@ -378,6 +324,11 @@ function useAppearanceSaveContributor({
|
||||
if (draftRef.current.settingsMenuMode !== submitted.settingsMenuMode) {
|
||||
previewMenuMode(draftRef.current.settingsMenuMode);
|
||||
}
|
||||
if (
|
||||
draftRef.current.richOutputAnimationsEnabled !== submitted.richOutputAnimationsEnabled
|
||||
) {
|
||||
previewRichOutputAnimations(draftRef.current.richOutputAnimationsEnabled);
|
||||
}
|
||||
const latestUserSettings = storeApi.getState().userSettings;
|
||||
let responseIsCurrent = false;
|
||||
if (response) {
|
||||
@@ -395,6 +346,7 @@ function useAppearanceSaveContributor({
|
||||
const confirmed = createAppearanceSavedState(
|
||||
submitted.theme,
|
||||
submitted.settingsMenuMode,
|
||||
submitted.richOutputAnimationsEnabled,
|
||||
nextUserSettings,
|
||||
);
|
||||
setSaved(confirmed);
|
||||
@@ -410,6 +362,7 @@ function useAppearanceSaveContributor({
|
||||
setDraft(saved);
|
||||
restoreTheme();
|
||||
restoreMenuMode();
|
||||
restoreRichOutputAnimations();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -439,23 +392,32 @@ function AppearanceSettingsSections({
|
||||
updateDraft,
|
||||
previewTheme,
|
||||
previewMenuMode,
|
||||
previewRichOutputAnimations,
|
||||
}: {
|
||||
draft: AppearanceState;
|
||||
saved: AppearanceState;
|
||||
updateDraft: (patch: Partial<AppearanceState>) => void;
|
||||
previewTheme: (theme: Theme) => void;
|
||||
previewMenuMode: (mode: SettingsMenuMode) => void;
|
||||
previewRichOutputAnimations: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<AppearanceThemeSection
|
||||
theme={draft.theme}
|
||||
isDirty={draft.theme !== saved.theme}
|
||||
onChange={(theme) => {
|
||||
isThemeDirty={draft.theme !== saved.theme}
|
||||
onThemeChange={(theme) => {
|
||||
updateDraft({ theme });
|
||||
previewTheme(theme);
|
||||
}}
|
||||
richOutputAnimationsEnabled={draft.richOutputAnimationsEnabled}
|
||||
isRichOutputMotionDirty={
|
||||
draft.richOutputAnimationsEnabled !== saved.richOutputAnimationsEnabled
|
||||
}
|
||||
onRichOutputMotionChange={(richOutputAnimationsEnabled) => {
|
||||
updateDraft({ richOutputAnimationsEnabled });
|
||||
previewRichOutputAnimations(richOutputAnimationsEnabled);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsMenuModeSection
|
||||
@@ -476,43 +438,7 @@ function AppearanceSettingsSections({
|
||||
/>
|
||||
|
||||
<LanguageSettings />
|
||||
|
||||
<AppStatusBarSettingsSection
|
||||
enabled={draft.appStatusBarEnabled}
|
||||
isDirty={draft.appStatusBarEnabled !== saved.appStatusBarEnabled}
|
||||
onChange={(appStatusBarEnabled) => updateDraft({ appStatusBarEnabled })}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title={t("settings:changesPanel")}
|
||||
description={t("settings:customizeHowChangedFilesAreDisplayed")}
|
||||
>
|
||||
<ChangesPanelLayoutCard
|
||||
value={draft.changesPanelLayout}
|
||||
isDirty={draft.changesPanelLayout !== saved.changesPanelLayout}
|
||||
onChange={(changesPanelLayout) => updateDraft({ changesPanelLayout })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconActivity className="h-5 w-5" />}
|
||||
title={t("settings:resourceMetrics")}
|
||||
description={t("settings:configureBackendAndExecutionResourceSampling")}
|
||||
>
|
||||
<SystemMetricsSettingsCard
|
||||
showInTopbar={draft.showMetrics}
|
||||
isShowInTopbarDirty={draft.showMetrics !== saved.showMetrics}
|
||||
onShowInTopbarChange={(showMetrics) => updateDraft({ showMetrics })}
|
||||
simplified={draft.simplifiedMetrics}
|
||||
isSimplifiedDirty={draft.simplifiedMetrics !== saved.simplifiedMetrics}
|
||||
onSimplifiedChange={(simplifiedMetrics) => updateDraft({ simplifiedMetrics })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<AppearanceAccountSections draft={draft} saved={saved} updateDraft={updateDraft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -522,9 +448,11 @@ export function AppearanceSettings() {
|
||||
const userSettings = useAppStore((state) => state.userSettings);
|
||||
const { savedTheme, previewTheme } = useTheme();
|
||||
const savedMenuMode = useAppStore((state) => state.settingsMenu.savedMode);
|
||||
const savedRichOutputAnimations = useAppStore((state) => state.richOutputMotion.savedEnabled);
|
||||
const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode);
|
||||
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
|
||||
const [saved, setSaved] = useState(() =>
|
||||
createAppearanceSavedState(savedTheme, savedMenuMode, userSettings),
|
||||
createAppearanceSavedState(savedTheme, savedMenuMode, savedRichOutputAnimations, userSettings),
|
||||
);
|
||||
const [draft, setDraft] = useState(saved);
|
||||
const draftRef = useRef(draft);
|
||||
@@ -538,6 +466,7 @@ export function AppearanceSettings() {
|
||||
const nextSaved = createAppearanceSavedState(
|
||||
previousSaved.theme,
|
||||
previousSaved.settingsMenuMode,
|
||||
previousSaved.richOutputAnimationsEnabled,
|
||||
userSettings,
|
||||
);
|
||||
setDraft(
|
||||
@@ -575,6 +504,7 @@ export function AppearanceSettings() {
|
||||
updateDraft={updateDraft}
|
||||
previewTheme={previewTheme}
|
||||
previewMenuMode={previewMenuMode}
|
||||
previewRichOutputAnimations={previewRichOutputAnimations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/lib/i18n";
|
||||
import { RichOutputMotionSettingsCard } from "./rich-output-motion-settings-card";
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
describe("RichOutputMotionSettingsCard", () => {
|
||||
it("renders a translated, controlled, touch-sized device preference", async () => {
|
||||
await i18n.changeLanguage("pseudo");
|
||||
const onChange = vi.fn();
|
||||
render(<RichOutputMotionSettingsCard enabled isDirty onChange={onChange} />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Àńĩḿàţē ŕĩćĥ-ōũţƥũţ ćĥàŕţś" });
|
||||
expect(toggle.getAttribute("data-state")).toBe("checked");
|
||||
expect(toggle.getAttribute("data-settings-dirty")).toBe("true");
|
||||
expect(screen.getByTestId("rich-output-motion-settings-card").dataset.settingsDirty).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByTestId("rich-output-motion-toggle-row").className).toContain("min-h-11");
|
||||
expect(toggle.className).toContain("data-[size=default]:h-11");
|
||||
expect(toggle.className).toContain("data-[size=default]:w-11");
|
||||
expect(toggle.className).toContain("data-[state=checked]:before:bg-primary");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Àńĩḿàţē ĺĩńē àńď ƀàŕ ćĥàŕţś ŵĥēń àĝēńţś ƥŕēśēńţ ţĥēḿ. Ţũŕń ţĥĩś ōƒƒ ţō ŕēďũćē ƀŕōŵśēŕ ŵōŕķ; ćĥàŕţ ďàţà àńď ćōńţŕōĺś śţàŷ àvàĩĺàƀĺē. Ŷōũŕ ōƥēŕàţĩńĝ śŷśţēḿ'ś ŕēďũćēď-ḿōţĩōń śēţţĩńĝ àĺŵàŷś ţàķēś ƥŕĩōŕĩţŷ. Śàvēď ōń ţĥĩś ďēvĩćē.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
|
||||
import { SettingsCard } from "./settings-card";
|
||||
|
||||
export function RichOutputMotionSettingsCard({
|
||||
enabled,
|
||||
isDirty,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isDirty: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsCard
|
||||
isDirty={isDirty}
|
||||
discoveryTargetId={GENERAL_SETTINGS_TARGETS.richOutputMotion}
|
||||
data-testid="rich-output-motion-settings-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("settings:richOutputChartMotion")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="flex min-h-11 items-center justify-between gap-4"
|
||||
data-testid="rich-output-motion-toggle-row"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label htmlFor="animate-rich-output-charts">
|
||||
{t("settings:animateRichOutputCharts")}
|
||||
</Label>
|
||||
<p className="max-w-3xl text-xs text-muted-foreground">
|
||||
{t("settings:animateRichOutputChartsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="animate-rich-output-charts"
|
||||
checked={enabled}
|
||||
onCheckedChange={onChange}
|
||||
data-settings-dirty={isDirty}
|
||||
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-11 data-[size=default]:w-11 cursor-pointer shrink-0 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +162,7 @@ type MessageRowProps = {
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
taskId?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
isLastGroup: boolean;
|
||||
activeTurnId: string | null;
|
||||
streamingMessageId: string | null;
|
||||
@@ -223,7 +223,7 @@ type NativeMessageListBodyProps = {
|
||||
messagesLoading: boolean;
|
||||
sessionState?: TaskSessionState;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
isInitialLoading: boolean;
|
||||
|
||||
@@ -32,7 +32,7 @@ export type MessageListProps = {
|
||||
isWorking: boolean;
|
||||
sessionState?: TaskSessionState;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
/** Render item key (see getItemKey) the unread "New" divider should
|
||||
* appear immediately before; null/undefined renders no divider. */
|
||||
dividerBeforeItemKey?: string | null;
|
||||
@@ -472,7 +472,7 @@ export const MessageItem = memo(function MessageItem({
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
taskId?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
isLastGroup: boolean;
|
||||
activeTurnId?: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
|
||||
@@ -41,7 +41,7 @@ type AdapterContext = {
|
||||
childrenByParentToolCallId?: Map<string, Message[]>;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
isTurnActive?: boolean;
|
||||
isContainingTurnActive?: boolean;
|
||||
@@ -331,7 +331,14 @@ const adapters: MessageAdapter[] = [
|
||||
const permissionMessage = toolCallId
|
||||
? ctx.permissionsByToolCallId?.get(toolCallId)
|
||||
: undefined;
|
||||
return <KandevToolMessage comment={comment} permissionMessage={permissionMessage} />;
|
||||
return (
|
||||
<KandevToolMessage
|
||||
comment={comment}
|
||||
permissionMessage={permissionMessage}
|
||||
sessionId={ctx.sessionId}
|
||||
onOpenFile={ctx.onOpenFile}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -440,7 +447,7 @@ type MessageRendererProps = {
|
||||
childrenByParentToolCallId?: Map<string, Message[]>;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
isTurnActive?: boolean;
|
||||
isContainingTurnActive?: boolean;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { KandevToolMessage, hasKandevRenderer } from "./kandev-tool-message";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
|
||||
import { KANDEV_RENDERERS } from "./kandev/registry";
|
||||
|
||||
// kandevToolCall constructs the kandev Message shape produced by the
|
||||
// orchestrator for an MCP tool_call. The shape mirrors live production data
|
||||
@@ -54,6 +55,9 @@ describe("hasKandevRenderer", () => {
|
||||
expect(
|
||||
hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__show_walkthrough_kandev" })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__show_rich_output_kandev" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated tools", () => {
|
||||
@@ -68,6 +72,95 @@ describe("hasKandevRenderer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("KandevToolMessage host context", () => {
|
||||
it("passes session and repository-aware file opening to a renderer", () => {
|
||||
const original = KANDEV_RENDERERS.list_tasks;
|
||||
const renderer = vi.fn(() => <div>Context probe</div>);
|
||||
const onOpenFile = vi.fn();
|
||||
KANDEV_RENDERERS.list_tasks = renderer;
|
||||
try {
|
||||
renderToStaticMarkup(
|
||||
<KandevToolMessage
|
||||
comment={kandevToolCall({ toolName: "mcp__kandev__list_tasks_kandev" })}
|
||||
sessionId="session-1"
|
||||
onOpenFile={onOpenFile}
|
||||
/>,
|
||||
);
|
||||
} finally {
|
||||
KANDEV_RENDERERS.list_tasks = original;
|
||||
}
|
||||
|
||||
expect(renderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: "session-1", onOpenFile }),
|
||||
);
|
||||
});
|
||||
|
||||
it("unwraps the real Codex ACP MCP arguments before rendering rich output", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<KandevToolMessage
|
||||
comment={kandevToolCall({
|
||||
toolName: "mcp.kandev.show_rich_output_kandev",
|
||||
input: {
|
||||
raw_input: {
|
||||
server: "kandev",
|
||||
tool: "show_rich_output_kandev",
|
||||
arguments: {
|
||||
version: 1,
|
||||
title: "Nested presentation",
|
||||
blocks: [{ type: "metrics", items: [{ label: "Passed", value: "38" }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Nested presentation");
|
||||
expect(html).not.toContain("This presentation is unavailable.");
|
||||
});
|
||||
|
||||
it("renders a CSV chart from the persisted tool-result snapshot", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<KandevToolMessage
|
||||
comment={kandevToolCall({
|
||||
toolName: "mcp__kandev__show_rich_output_kandev",
|
||||
input: {
|
||||
version: 1,
|
||||
title: "CSV presentation",
|
||||
blocks: [
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "bar",
|
||||
title: "Requests by route",
|
||||
summary: "Request volume from the workspace CSV.",
|
||||
csv: {
|
||||
path: "reports/routes.csv",
|
||||
x_column: "route",
|
||||
series: [{ column: "requests" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resultJson: {
|
||||
version: 1,
|
||||
resolved_charts: [
|
||||
{
|
||||
block_index: 0,
|
||||
labels: ["/api", "/health"],
|
||||
series: [{ label: "requests", values: [2400, 800] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("CSV presentation");
|
||||
expect(html).toContain('data-testid="rich-output-chart-bar"');
|
||||
expect(html).not.toContain("This presentation is unavailable.");
|
||||
});
|
||||
});
|
||||
|
||||
// State labels and titles reused across multiple tool-DTOs. Pulled to
|
||||
// constants to satisfy the "no duplicate string" lint rule.
|
||||
const COMPLETED = "COMPLETED";
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { ToolCallMetadata } from "@/components/task/chat/types";
|
||||
import { extractKandevStem, extractMcpResult } from "./kandev/parse";
|
||||
import { kandevToolStemOf, type ToolCallMetadata } from "@/components/task/chat/types";
|
||||
import { extractKandevArgs, extractMcpResult } from "./kandev/parse";
|
||||
import { getKandevRenderer } from "./kandev/registry";
|
||||
import { PermissionActionRow } from "./permission-action-row";
|
||||
import { normalizeToolCallStatus } from "./tool-status";
|
||||
@@ -26,34 +26,10 @@ function derivePermissionUI(
|
||||
type KandevToolMessageProps = {
|
||||
comment: Message;
|
||||
permissionMessage?: Message;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
};
|
||||
|
||||
// kandevStemOf scans the several fields that may carry the raw MCP tool name
|
||||
// and returns the first one that parses to a known kandev stem. The fields
|
||||
// disagree in practice:
|
||||
// - `metadata.tool_name` — not set by the orchestrator today (null).
|
||||
// - `metadata.title` — the raw `mcp__kandev__<tool>_kandev` string.
|
||||
// - `comment.content` — same raw string, redundant with title.
|
||||
// - `metadata.normalized.generic.name` — the ACP adapter's *category*
|
||||
// (often `"other"`) rather than the tool name, so it cannot be matched on.
|
||||
// We iterate candidates instead of picking a single "preferred" one because
|
||||
// the live data showed `generic.name = "other"`, which would short-circuit
|
||||
// any priority-based ordering on the wrong field.
|
||||
function kandevStemOf(comment: Message): string | null {
|
||||
const meta = comment.metadata as ToolCallMetadata | undefined;
|
||||
const candidates: Array<string | undefined> = [
|
||||
meta?.tool_name,
|
||||
meta?.title,
|
||||
comment.content || undefined,
|
||||
meta?.normalized?.generic?.name,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const stem = extractKandevStem(candidate);
|
||||
if (stem) return stem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// hasKandevRenderer is the matcher used by the message dispatcher. It accepts
|
||||
// any `tool_call` whose tool name is recognised as a Kandev MCP tool AND for
|
||||
// which a per-tool renderer is registered. We require both because we still
|
||||
@@ -61,7 +37,7 @@ function kandevStemOf(comment: Message): string | null {
|
||||
// (rather than rendering an empty row) until a dedicated renderer ships.
|
||||
export function hasKandevRenderer(comment: Message): boolean {
|
||||
if (comment.type !== "tool_call") return false;
|
||||
return !!getKandevRenderer(kandevStemOf(comment));
|
||||
return !!getKandevRenderer(kandevToolStemOf(comment));
|
||||
}
|
||||
|
||||
// KandevToolMessage is the rendered entry point for every Kandev tool call.
|
||||
@@ -73,9 +49,11 @@ export function hasKandevRenderer(comment: Message): boolean {
|
||||
export const KandevToolMessage = memo(function KandevToolMessage({
|
||||
comment,
|
||||
permissionMessage,
|
||||
sessionId,
|
||||
onOpenFile,
|
||||
}: KandevToolMessageProps) {
|
||||
const meta = comment.metadata as ToolCallMetadata | undefined;
|
||||
const renderer = getKandevRenderer(kandevStemOf(comment));
|
||||
const renderer = getKandevRenderer(kandevToolStemOf(comment));
|
||||
const { permissionMetadata, permissionStatus, isPermissionPending } =
|
||||
parsePermission(permissionMessage);
|
||||
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
|
||||
@@ -91,8 +69,7 @@ export const KandevToolMessage = memo(function KandevToolMessage({
|
||||
// component still works for any code path that hasn't routed through the
|
||||
// normalizer yet.
|
||||
const generic = meta?.normalized?.generic;
|
||||
const argsCandidate = (generic?.input as Record<string, unknown> | undefined) ?? meta?.args;
|
||||
const args = argsCandidate && typeof argsCandidate === "object" ? argsCandidate : undefined;
|
||||
const args = extractKandevArgs(generic?.input) ?? extractKandevArgs(meta?.args);
|
||||
const rawResult = generic?.output ?? meta?.result;
|
||||
const result = extractMcpResult(rawResult);
|
||||
const status = normalizeToolCallStatus(meta?.status);
|
||||
@@ -104,7 +81,7 @@ export const KandevToolMessage = memo(function KandevToolMessage({
|
||||
// and avoids the lint rule against "components created during render".
|
||||
const rendered = (
|
||||
<KandevPermissionUIProvider value={permissionUI}>
|
||||
{renderer({ args, result, status })}
|
||||
{renderer({ args, result, status, sessionId, onOpenFile })}
|
||||
</KandevPermissionUIProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractKandevStem, extractMcpResult, shortId } from "./parse";
|
||||
import { extractKandevArgs, extractKandevStem, extractMcpResult, shortId } from "./parse";
|
||||
|
||||
describe("extractKandevArgs", () => {
|
||||
it("unwraps arguments from the real Codex ACP MCP envelope", () => {
|
||||
const argumentsValue = { version: 1, title: "Service failures", blocks: [] };
|
||||
expect(
|
||||
extractKandevArgs({
|
||||
raw_input: {
|
||||
server: "kandev",
|
||||
tool: "show_rich_output_kandev",
|
||||
arguments: argumentsValue,
|
||||
},
|
||||
}),
|
||||
).toEqual(argumentsValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKandevStem", () => {
|
||||
it("strips the mcp__kandev__ namespace and the _kandev suffix", () => {
|
||||
@@ -10,6 +25,10 @@ describe("extractKandevStem", () => {
|
||||
expect(extractKandevStem("kandev/list_tasks_kandev")).toBe("list_tasks");
|
||||
});
|
||||
|
||||
it("handles the dotted Codex ACP title fallback", () => {
|
||||
expect(extractKandevStem("mcp.kandev.show_rich_output_kandev")).toBe("show_rich_output");
|
||||
});
|
||||
|
||||
it("handles a bare suffix-only name", () => {
|
||||
expect(extractKandevStem("create_task_kandev")).toBe("create_task");
|
||||
});
|
||||
@@ -68,6 +87,35 @@ describe("extractMcpResult", () => {
|
||||
it("returns plain objects untouched", () => {
|
||||
expect(extractMcpResult({ foo: 1 })).toEqual({ foo: 1 });
|
||||
});
|
||||
|
||||
it("prefers standard MCP structuredContent over its text fallback", () => {
|
||||
expect(
|
||||
extractMcpResult({
|
||||
content: [{ type: "text", text: "fallback" }],
|
||||
structuredContent: { version: 1, resolved_charts: [] },
|
||||
}),
|
||||
).toEqual({ version: 1, resolved_charts: [] });
|
||||
});
|
||||
|
||||
it("unwraps the raw result wrapper emitted by ACP clients", () => {
|
||||
expect(extractMcpResult({ result: '{"version":1,"resolved_charts":[]}' })).toEqual({
|
||||
version: 1,
|
||||
resolved_charts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps the real Codex ACP rawOutput result object", () => {
|
||||
const snapshot = { version: 1, resolved_charts: [] };
|
||||
expect(
|
||||
extractMcpResult({
|
||||
error: null,
|
||||
result: {
|
||||
content: [{ type: "text", text: JSON.stringify(snapshot) }],
|
||||
structuredContent: snapshot,
|
||||
},
|
||||
}),
|
||||
).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortId", () => {
|
||||
|
||||
@@ -13,12 +13,31 @@
|
||||
// usually an array of `{type, text}` content blocks where `text` is itself a
|
||||
// JSON-encoded string; we parse that inner JSON so the renderers see plain JS.
|
||||
|
||||
const NAMESPACE_SEP = /\/|__/;
|
||||
const NAMESPACE_SEP = /\/|__|\./;
|
||||
const KANDEV_NAMESPACES = ["mcp__kandev__", "mcp.kandev.", "kandev/"] as const;
|
||||
const KANDEV_SUFFIX = "_kandev";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ACP stores generic tool arguments below `input.raw_input`; older/direct
|
||||
// transports store the arguments at `input`. Normalize both at the renderer
|
||||
// boundary so every Kandev tool sees its actual call arguments.
|
||||
export function extractKandevArgs(value: unknown): Record<string, unknown> | undefined {
|
||||
const input = asRecord(value);
|
||||
if (!input) return undefined;
|
||||
const rawInput = asRecord(input.raw_input);
|
||||
return asRecord(rawInput?.arguments) ?? rawInput ?? input;
|
||||
}
|
||||
|
||||
export function extractKandevStem(toolName: string | undefined): string | null {
|
||||
if (!toolName) return null;
|
||||
const tail = toolName.trim().split(NAMESPACE_SEP).pop() ?? "";
|
||||
const normalized = toolName.trim();
|
||||
const namespace = KANDEV_NAMESPACES.find((prefix) => normalized.startsWith(prefix));
|
||||
const tail = namespace ? normalized.slice(namespace.length) : normalized;
|
||||
if ((!namespace && NAMESPACE_SEP.test(normalized)) || NAMESPACE_SEP.test(tail)) return null;
|
||||
if (!tail.endsWith(KANDEV_SUFFIX)) return null;
|
||||
const stem = tail.slice(0, -KANDEV_SUFFIX.length);
|
||||
return stem.length > 0 ? stem : null;
|
||||
@@ -63,9 +82,23 @@ export function extractMcpResult(value: unknown): unknown {
|
||||
return unwrapContentBlocks(value);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const obj = value as { content?: unknown; output?: unknown };
|
||||
const obj = value as {
|
||||
content?: unknown;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
structuredContent?: unknown;
|
||||
structured_content?: unknown;
|
||||
};
|
||||
if (obj.structuredContent !== undefined) return obj.structuredContent;
|
||||
if (obj.structured_content !== undefined) return obj.structured_content;
|
||||
if (Array.isArray(obj.content)) return unwrapContentBlocks(obj.content);
|
||||
if (typeof obj.output === "string") return tryParseJson(obj.output);
|
||||
if (
|
||||
obj.result !== undefined &&
|
||||
(Object.hasOwn(obj, "error") || Object.keys(obj).length === 1)
|
||||
) {
|
||||
return extractMcpResult(obj.result);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
@@ -90,10 +123,9 @@ export function pickArray<T = unknown>(obj: unknown, key: string): T[] | undefin
|
||||
}
|
||||
|
||||
export function pickObject(obj: unknown, key: string): Record<string, unknown> | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
const v = (obj as Record<string, unknown>)[key];
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||
return v as Record<string, unknown>;
|
||||
const record = asRecord(obj);
|
||||
if (!record) return undefined;
|
||||
return asRecord(record[key]);
|
||||
}
|
||||
|
||||
// Shorten a UUID-like identifier for inline display (e.g. "abc12345…").
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./document-renderers";
|
||||
import { AskUserQuestionRenderer } from "./ask-user-question-renderer";
|
||||
import { ShowWalkthroughRenderer } from "./walkthrough-renderer";
|
||||
import { RichOutputRenderer } from "./rich-output/rich-output-renderer";
|
||||
import type { KandevRenderer } from "./types";
|
||||
|
||||
export const KANDEV_RENDERERS: Record<string, KandevRenderer> = {
|
||||
@@ -56,6 +57,7 @@ export const KANDEV_RENDERERS: Record<string, KandevRenderer> = {
|
||||
|
||||
ask_user_question: AskUserQuestionRenderer,
|
||||
show_walkthrough: ShowWalkthroughRenderer,
|
||||
show_rich_output: RichOutputRenderer,
|
||||
};
|
||||
|
||||
export function getKandevRenderer(stem: string | null): KandevRenderer | null {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RichOutputChartBlock } from "./types";
|
||||
|
||||
type ObserverRecord = {
|
||||
callback: IntersectionObserverCallback;
|
||||
instance: IntersectionObserver;
|
||||
options?: IntersectionObserverInit;
|
||||
target?: Element;
|
||||
};
|
||||
|
||||
const observerRecords: ObserverRecord[] = [];
|
||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
const MOCK_LINE_CHART = "mock-line-chart";
|
||||
const chartMotion = vi.hoisted(() => ({ enabled: true }));
|
||||
const captured = vi.hoisted(() => ({
|
||||
bars: [] as Array<Record<string, unknown>>,
|
||||
chartContainers: [] as Array<Record<string, unknown>>,
|
||||
lineCharts: [] as Array<Record<string, unknown>>,
|
||||
lines: [] as Array<Record<string, unknown>>,
|
||||
xAxes: [] as Array<Record<string, unknown>>,
|
||||
yAxes: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("./chart-motion", () => ({
|
||||
useRichOutputChartAnimations: () => chartMotion.enabled,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/chart", () => ({
|
||||
ChartContainer: (props: { children: ReactNode }) => {
|
||||
captured.chartContainers.push(props);
|
||||
return <div>{props.children}</div>;
|
||||
},
|
||||
ChartLegend: ({ content }: { content: ReactNode }) => <>{content}</>,
|
||||
ChartTooltip: () => null,
|
||||
ChartTooltipContent: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
Bar: (props: Record<string, unknown>) => {
|
||||
captured.bars.push(props);
|
||||
return null;
|
||||
},
|
||||
BarChart: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="mock-bar-chart">{children}</div>
|
||||
),
|
||||
CartesianGrid: () => null,
|
||||
Line: (props: Record<string, unknown>) => {
|
||||
captured.lines.push(props);
|
||||
return null;
|
||||
},
|
||||
LineChart: (props: { children: ReactNode }) => {
|
||||
captured.lineCharts.push(props);
|
||||
return <div data-testid="mock-line-chart">{props.children}</div>;
|
||||
},
|
||||
XAxis: (props: Record<string, unknown>) => {
|
||||
captured.xAxes.push(props);
|
||||
return null;
|
||||
},
|
||||
YAxis: (props: Record<string, unknown>) => {
|
||||
captured.yAxes.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { ChartBlock } from "./chart-block";
|
||||
|
||||
const BLOCK: RichOutputChartBlock = {
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Latency",
|
||||
summary: "Latency over time",
|
||||
labels: ["Mon", "Tue"],
|
||||
series: [{ label: "p95", values: [120, 110] }],
|
||||
};
|
||||
|
||||
function installIntersectionObserver() {
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly thresholds: readonly number[] = [];
|
||||
private readonly record: ObserverRecord;
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.record = { callback, instance: this, options };
|
||||
observerRecords.push(this.record);
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
observe(target: Element) {
|
||||
this.record.target = target;
|
||||
}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||
}
|
||||
|
||||
function intersect(record = observerRecords[0]) {
|
||||
act(() => {
|
||||
record.callback(
|
||||
[{ isIntersecting: true, target: record.target } as IntersectionObserverEntry],
|
||||
record.instance,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setDocumentVisibility(value: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observerRecords.length = 0;
|
||||
for (const values of Object.values(captured)) values.length = 0;
|
||||
installIntersectionObserver();
|
||||
chartMotion.enabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
if (visibilityDescriptor) {
|
||||
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(document, "visibilityState");
|
||||
}
|
||||
});
|
||||
|
||||
describe("ChartBlock plot scheduling", () => {
|
||||
it("defers the Recharts plot until the chart approaches the viewport", () => {
|
||||
render(<ChartBlock block={BLOCK} />);
|
||||
|
||||
expect(screen.queryByTestId(MOCK_LINE_CHART)).toBeNull();
|
||||
expect(screen.getByText(BLOCK.title)).not.toBeNull();
|
||||
expect(observerRecords[0].options?.rootMargin).toBe("200px 0px");
|
||||
|
||||
intersect();
|
||||
expect(screen.getByTestId(MOCK_LINE_CHART)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("waits for a background tab to become visible after intersection", () => {
|
||||
setDocumentVisibility("hidden");
|
||||
render(<ChartBlock block={BLOCK} />);
|
||||
|
||||
intersect();
|
||||
expect(screen.queryByTestId(MOCK_LINE_CHART)).toBeNull();
|
||||
|
||||
act(() => setDocumentVisibility("visible"));
|
||||
expect(screen.getByTestId(MOCK_LINE_CHART)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an eligible plot mounted after it leaves the visible tab", () => {
|
||||
render(<ChartBlock block={BLOCK} />);
|
||||
intersect();
|
||||
const mountedPlot = screen.getByTestId(MOCK_LINE_CHART);
|
||||
|
||||
act(() => setDocumentVisibility("hidden"));
|
||||
|
||||
expect(screen.getByTestId(MOCK_LINE_CHART)).toBe(mountedPlot);
|
||||
});
|
||||
|
||||
it("renders immediately when intersection observation is unavailable", () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
|
||||
render(<ChartBlock block={BLOCK} />);
|
||||
|
||||
expect(screen.getByTestId(MOCK_LINE_CHART)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reuses derived chart inputs when a legend toggle changes local visibility", () => {
|
||||
const block: RichOutputChartBlock = {
|
||||
...BLOCK,
|
||||
series: [BLOCK.series[0], { label: "p50", values: [80, 75] }],
|
||||
};
|
||||
render(<ChartBlock block={block} />);
|
||||
intersect();
|
||||
|
||||
const initialData = captured.lineCharts.at(-1)?.data;
|
||||
const initialConfig = captured.chartContainers.at(-1)?.config;
|
||||
const initialXAxisFormatter = captured.xAxes.at(-1)?.tickFormatter;
|
||||
const initialYAxisFormatter = captured.yAxes.at(-1)?.tickFormatter;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "p50" }));
|
||||
|
||||
expect(captured.lineCharts.at(-1)?.data).toBe(initialData);
|
||||
expect(captured.chartContainers.at(-1)?.config).toBe(initialConfig);
|
||||
expect(captured.xAxes.at(-1)?.tickFormatter).toBe(initialXAxisFormatter);
|
||||
expect(captured.yAxes.at(-1)?.tickFormatter).toBe(initialYAxisFormatter);
|
||||
expect(captured.lines.at(-1)?.hide).toBe(true);
|
||||
});
|
||||
|
||||
it("skips an unchanged parent rerender and retains default chart animation", () => {
|
||||
const view = render(<ChartBlock block={BLOCK} />);
|
||||
intersect();
|
||||
const renderCount = captured.lineCharts.length;
|
||||
|
||||
view.rerender(<ChartBlock block={BLOCK} />);
|
||||
|
||||
expect(captured.lineCharts).toHaveLength(renderCount);
|
||||
expect(captured.lines.at(-1)?.isAnimationActive).not.toBe(false);
|
||||
});
|
||||
|
||||
it("disables every line and bar animation when effective chart motion is off", () => {
|
||||
chartMotion.enabled = false;
|
||||
const line = render(<ChartBlock block={BLOCK} />);
|
||||
intersect();
|
||||
|
||||
expect(captured.lines.at(-1)?.isAnimationActive).toBe(false);
|
||||
|
||||
line.unmount();
|
||||
observerRecords.length = 0;
|
||||
const barBlock: RichOutputChartBlock = { ...BLOCK, chart_type: "bar" };
|
||||
render(<ChartBlock block={barBlock} />);
|
||||
intersect();
|
||||
|
||||
expect(captured.bars.at(-1)?.isAnimationActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@kandev/ui/chart";
|
||||
import { createXAxisTickFormatter, formatYAxisTick } from "./chart-format";
|
||||
import { useRichOutputChartAnimations } from "./chart-motion";
|
||||
import { useChartPlotVisibility } from "./chart-visibility";
|
||||
import type { RichOutputChartBlock } from "./types";
|
||||
|
||||
const HOST_SERIES_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
] as const;
|
||||
const CHART_MARGIN = { left: 0, right: 8 } as const;
|
||||
const BAR_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
|
||||
|
||||
function seriesKey(index: number): string {
|
||||
return `series_${index}`;
|
||||
}
|
||||
|
||||
function chartConfig(block: RichOutputChartBlock): ChartConfig {
|
||||
return Object.fromEntries(
|
||||
block.series.map((series, index) => [
|
||||
seriesKey(index),
|
||||
{ label: series.label, color: HOST_SERIES_COLORS[index] },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function chartData(block: RichOutputChartBlock): Array<Record<string, string | number | null>> {
|
||||
return block.labels.map((label, labelIndex) => {
|
||||
const row: Record<string, string | number | null> = { label };
|
||||
for (let seriesIndex = 0; seriesIndex < block.series.length; seriesIndex += 1) {
|
||||
row[seriesKey(seriesIndex)] = block.series[seriesIndex].values[labelIndex];
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function SeriesMarker({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesLegend({
|
||||
block,
|
||||
hiddenSeries,
|
||||
onToggle,
|
||||
}: {
|
||||
block: RichOutputChartBlock;
|
||||
hiddenSeries: ReadonlySet<string>;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
const isInteractive = block.series.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-1 gap-y-0 pt-1">
|
||||
{block.series.map((series, index) => {
|
||||
const key = seriesKey(index);
|
||||
const isVisible = !hiddenSeries.has(key);
|
||||
const content = (
|
||||
<>
|
||||
<SeriesMarker color={HOST_SERIES_COLORS[index]} />
|
||||
<span className={isVisible ? undefined : "line-through"}>{series.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return isInteractive ? (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={isVisible}
|
||||
className={`flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground transition-[opacity,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.98] ${isVisible ? "opacity-100" : "opacity-50"}`}
|
||||
data-testid={`rich-output-chart-legend-${key}`}
|
||||
onClick={() => onToggle(key)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={key}
|
||||
className="flex min-h-7 items-center gap-1.5 px-2 text-[11px] text-muted-foreground"
|
||||
data-testid={`rich-output-chart-legend-${key}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useChartPresentation(block: RichOutputChartBlock) {
|
||||
const { i18n } = useTranslation();
|
||||
const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||
const formatXAxisTick = useMemo(
|
||||
() => createXAxisTickFormatter(block.labels, locale),
|
||||
[block.labels, locale],
|
||||
);
|
||||
const formatYAxisValue = useCallback(
|
||||
(value: unknown) => formatYAxisTick(value, locale),
|
||||
[locale],
|
||||
);
|
||||
const toggleSeries = useCallback((key: string) => {
|
||||
setHiddenSeries((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { formatXAxisTick, formatYAxisValue, hiddenSeries, toggleSeries };
|
||||
}
|
||||
|
||||
export const ChartBlock = memo(function ChartBlock({ block }: { block: RichOutputChartBlock }) {
|
||||
const { formatXAxisTick, formatYAxisValue, hiddenSeries, toggleSeries } =
|
||||
useChartPresentation(block);
|
||||
const { plotRef, shouldMountPlot } = useChartPlotVisibility();
|
||||
const shouldAnimate = useRichOutputChartAnimations();
|
||||
const data = useMemo(() => chartData(block), [block.labels, block.series]);
|
||||
const config = useMemo(() => chartConfig(block), [block.series]);
|
||||
const legend = useMemo(
|
||||
() => (
|
||||
<ChartLegend
|
||||
content={<SeriesLegend block={block} hiddenSeries={hiddenSeries} onToggle={toggleSeries} />}
|
||||
/>
|
||||
),
|
||||
[block, hiddenSeries, toggleSeries],
|
||||
);
|
||||
|
||||
return (
|
||||
<figure className="min-w-0 space-y-3" data-testid={`rich-output-chart-${block.chart_type}`}>
|
||||
<figcaption className="space-y-0.5">
|
||||
<h4 className="text-xs font-medium text-foreground">{block.title}</h4>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{block.summary}</p>
|
||||
</figcaption>
|
||||
<div ref={plotRef} className="h-52 min-h-52 w-full min-w-0 max-w-full">
|
||||
{shouldMountPlot && (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="h-full min-h-52 w-full min-w-0 max-w-full aspect-auto"
|
||||
aria-label={block.summary}
|
||||
>
|
||||
{block.chart_type === "line" ? (
|
||||
<LineChart data={data} accessibilityLayer margin={CHART_MARGIN}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickFormatter={formatXAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={20}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatYAxisValue}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{block.series.map((series, index) => (
|
||||
<Line
|
||||
key={`${series.label}-${index}`}
|
||||
dataKey={seriesKey(index)}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${seriesKey(index)})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={shouldAnimate}
|
||||
hide={hiddenSeries.has(seriesKey(index))}
|
||||
/>
|
||||
))}
|
||||
{legend}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} accessibilityLayer margin={CHART_MARGIN}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickFormatter={formatXAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={20}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatYAxisValue}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{block.series.map((series, index) => (
|
||||
<Bar
|
||||
key={`${series.label}-${index}`}
|
||||
dataKey={seriesKey(index)}
|
||||
fill={`var(--color-${seriesKey(index)})`}
|
||||
radius={BAR_RADIUS}
|
||||
isAnimationActive={shouldAnimate}
|
||||
hide={hiddenSeries.has(seriesKey(index))}
|
||||
/>
|
||||
))}
|
||||
{legend}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createXAxisTickFormatter, formatYAxisTick } from "./chart-format";
|
||||
|
||||
const AUGUST_12_TIMESTAMP = "2026-08-12T10:00:00Z";
|
||||
|
||||
describe("createXAxisTickFormatter", () => {
|
||||
it("keeps short categories and clips long categories", () => {
|
||||
const format = createXAxisTickFormatter(["/api", "very-long-category-name"], "en-US");
|
||||
|
||||
expect(format("/api")).toBe("/api");
|
||||
expect(format("very-long-category-name")).toBe("very-long-categ…");
|
||||
});
|
||||
|
||||
it("formats ISO timestamps on distinct days as compact dates", () => {
|
||||
const format = createXAxisTickFormatter([AUGUST_12_TIMESTAMP, "2026-08-13T10:00:00Z"], "en-US");
|
||||
|
||||
expect(format(AUGUST_12_TIMESTAMP)).toBe("Aug 12");
|
||||
expect(format("2026-08-13T10:00:00Z")).toBe("Aug 13");
|
||||
});
|
||||
|
||||
it("includes time when timestamps share a calendar day", () => {
|
||||
const first = AUGUST_12_TIMESTAMP;
|
||||
const second = "2026-08-12T14:30:00Z";
|
||||
const format = createXAxisTickFormatter([first, second], "en-US");
|
||||
|
||||
expect(format(first)).toContain("Aug 12");
|
||||
expect(format(first)).toContain("10:00");
|
||||
expect(format(second)).toContain("14:30");
|
||||
expect(format(first)).not.toBe(format(second));
|
||||
});
|
||||
|
||||
it("falls back safely for the pseudo locale", () => {
|
||||
const format = createXAxisTickFormatter([AUGUST_12_TIMESTAMP], "pseudo");
|
||||
|
||||
expect(format(AUGUST_12_TIMESTAMP)).toBe("Aug 12");
|
||||
expect(formatYAxisTick(2_400, "pseudo")).toBe("2.4K");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatYAxisTick", () => {
|
||||
it("uses compact notation for large values", () => {
|
||||
expect(formatYAxisTick(2_400, "en-US")).toBe("2.4K");
|
||||
expect(formatYAxisTick(-12_500, "en-US")).toBe("-12.5K");
|
||||
});
|
||||
|
||||
it("preserves useful precision for ordinary and small values", () => {
|
||||
expect(formatYAxisTick(29.4, "en-US")).toBe("29.4");
|
||||
expect(formatYAxisTick(0.0042, "en-US")).toBe("0.0042");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const MAX_CATEGORY_TICK_CHARACTERS = 16;
|
||||
const ISO_DATE_OR_TIMESTAMP =
|
||||
/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2}))?$/;
|
||||
|
||||
type YAxisFormatters = {
|
||||
compact: Intl.NumberFormat;
|
||||
significant: Intl.NumberFormat;
|
||||
fixed: Intl.NumberFormat;
|
||||
};
|
||||
|
||||
const yAxisFormatterCache = new Map<string, YAxisFormatters>();
|
||||
|
||||
function resolveIntlLocale(locale: string): string {
|
||||
try {
|
||||
return Intl.NumberFormat.supportedLocalesOf([locale])[0] ?? "en-US";
|
||||
} catch {
|
||||
return "en-US";
|
||||
}
|
||||
}
|
||||
|
||||
function clipCategoryTick(value: string): string {
|
||||
const characters = Array.from(value);
|
||||
if (characters.length <= MAX_CATEGORY_TICK_CHARACTERS) return value;
|
||||
return `${characters.slice(0, MAX_CATEGORY_TICK_CHARACTERS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function yAxisFormatters(locale: string): YAxisFormatters {
|
||||
const supportedLocale = resolveIntlLocale(locale);
|
||||
const cached = yAxisFormatterCache.get(supportedLocale);
|
||||
if (cached) return cached;
|
||||
const formatters = {
|
||||
compact: new Intl.NumberFormat(supportedLocale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
significant: new Intl.NumberFormat(supportedLocale, { maximumSignificantDigits: 2 }),
|
||||
fixed: new Intl.NumberFormat(supportedLocale, { maximumFractionDigits: 2 }),
|
||||
};
|
||||
yAxisFormatterCache.set(supportedLocale, formatters);
|
||||
return formatters;
|
||||
}
|
||||
|
||||
function parseISODate(value: string): Date | null {
|
||||
if (!ISO_DATE_OR_TIMESTAMP.test(value)) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function createXAxisTickFormatter(
|
||||
labels: string[],
|
||||
locale: string,
|
||||
): (value: unknown) => string {
|
||||
const dates = labels.map(parseISODate);
|
||||
const allDates = dates.every((date): date is Date => date !== null);
|
||||
|
||||
if (!allDates) {
|
||||
return (value) => clipCategoryTick(String(value ?? ""));
|
||||
}
|
||||
|
||||
const supportedLocale = resolveIntlLocale(locale);
|
||||
const calendarDays = new Set(dates.map((date) => date.toISOString().slice(0, 10)));
|
||||
const includeTime = calendarDays.size < dates.length;
|
||||
const formatter = new Intl.DateTimeFormat(supportedLocale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(includeTime ? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" } : {}),
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
return (value) => {
|
||||
const rawValue = String(value ?? "");
|
||||
const date = parseISODate(rawValue);
|
||||
return date ? formatter.format(date) : clipCategoryTick(rawValue);
|
||||
};
|
||||
}
|
||||
|
||||
export function formatYAxisTick(value: unknown, locale: string): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return clipCategoryTick(String(value ?? ""));
|
||||
}
|
||||
|
||||
const absoluteValue = Math.abs(value);
|
||||
const formatters = yAxisFormatters(locale);
|
||||
if (absoluteValue >= 1_000) {
|
||||
return formatters.compact.format(value);
|
||||
}
|
||||
|
||||
if (absoluteValue > 0 && absoluteValue < 0.01) {
|
||||
return formatters.significant.format(value);
|
||||
}
|
||||
|
||||
return formatters.fixed.format(value);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const storeState = vi.hoisted(() => ({ enabled: true }));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useOptionalAppStore: (selector: (state: { richOutputMotion: { enabled: boolean } }) => unknown) =>
|
||||
selector({ richOutputMotion: storeState }),
|
||||
}));
|
||||
|
||||
import { useRichOutputChartAnimations } from "./chart-motion";
|
||||
|
||||
type MediaListener = (event: MediaQueryListEvent) => void;
|
||||
|
||||
let reducedMotion = false;
|
||||
let listeners: MediaListener[] = [];
|
||||
|
||||
function installMatchMedia() {
|
||||
window.matchMedia = vi.fn(
|
||||
() =>
|
||||
({
|
||||
get matches() {
|
||||
return reducedMotion;
|
||||
},
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: MediaListener) => listeners.push(listener),
|
||||
removeEventListener: (_type: string, listener: MediaListener) => {
|
||||
listeners = listeners.filter((candidate) => candidate !== listener);
|
||||
},
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as MediaQueryList,
|
||||
);
|
||||
}
|
||||
|
||||
function setReducedMotion(matches: boolean) {
|
||||
reducedMotion = matches;
|
||||
const event = { matches, media: "(prefers-reduced-motion: reduce)" } as MediaQueryListEvent;
|
||||
for (const listener of listeners) listener(event);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
listeners = [];
|
||||
storeState.enabled = true;
|
||||
installMatchMedia();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useRichOutputChartAnimations", () => {
|
||||
it("uses the enabled device preference when OS motion is allowed", () => {
|
||||
const { result } = renderHook(() => useRichOutputChartAnimations());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("disables animations for the explicit device opt-out", () => {
|
||||
storeState.enabled = false;
|
||||
|
||||
const { result } = renderHook(() => useRichOutputChartAnimations());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("honors live reduced-motion changes without reloading", () => {
|
||||
const { result } = renderHook(() => useRichOutputChartAnimations());
|
||||
|
||||
act(() => setReducedMotion(true));
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => setReducedMotion(false));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useOptionalAppStore } from "@/components/state-provider";
|
||||
|
||||
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
const reducedMotionListeners = new Set<() => void>();
|
||||
let reducedMotionMediaQuery: MediaQueryList | undefined;
|
||||
|
||||
function getReducedMotionMediaQuery(): MediaQueryList | undefined {
|
||||
if (reducedMotionMediaQuery) return reducedMotionMediaQuery;
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
||||
reducedMotionMediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
return reducedMotionMediaQuery;
|
||||
}
|
||||
|
||||
function notifyReducedMotionListeners() {
|
||||
for (const listener of reducedMotionListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeToReducedMotion(listener: () => void): () => void {
|
||||
const mediaQuery = getReducedMotionMediaQuery();
|
||||
reducedMotionListeners.add(listener);
|
||||
if (reducedMotionListeners.size === 1 && mediaQuery) {
|
||||
mediaQuery.addEventListener("change", notifyReducedMotionListeners);
|
||||
}
|
||||
return () => {
|
||||
reducedMotionListeners.delete(listener);
|
||||
if (reducedMotionListeners.size === 0 && mediaQuery) {
|
||||
mediaQuery.removeEventListener("change", notifyReducedMotionListeners);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getReducedMotionSnapshot(): boolean {
|
||||
return getReducedMotionMediaQuery()?.matches ?? false;
|
||||
}
|
||||
|
||||
export function useRichOutputChartAnimations(): boolean {
|
||||
const enabled = useOptionalAppStore((state) => state.richOutputMotion.enabled, true);
|
||||
const reducedMotion = useSyncExternalStore(
|
||||
subscribeToReducedMotion,
|
||||
getReducedMotionSnapshot,
|
||||
() => false,
|
||||
);
|
||||
return enabled && !reducedMotion;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// i18n-exempt: IntersectionObserver geometry, not user-facing copy.
|
||||
const CHART_PREWARM_MARGIN = "200px 0px";
|
||||
|
||||
export function useChartPlotVisibility() {
|
||||
const plotRef = useRef<HTMLDivElement | null>(null);
|
||||
const canObserveIntersection = typeof IntersectionObserver !== "undefined";
|
||||
const [isNearViewport, setIsNearViewport] = useState(!canObserveIntersection);
|
||||
const [isDocumentVisible, setIsDocumentVisible] = useState(
|
||||
() => typeof document === "undefined" || document.visibilityState !== "hidden",
|
||||
);
|
||||
const [shouldMountPlot, setShouldMountPlot] = useState(() => !canObserveIntersection);
|
||||
|
||||
useEffect(() => {
|
||||
const plot = plotRef.current;
|
||||
if (shouldMountPlot || !plot || !canObserveIntersection) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
setIsNearViewport(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: CHART_PREWARM_MARGIN },
|
||||
);
|
||||
observer.observe(plot);
|
||||
return () => observer.disconnect();
|
||||
}, [canObserveIntersection, shouldMountPlot]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsDocumentVisible(document.visibilityState !== "hidden");
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNearViewport && isDocumentVisible) setShouldMountPlot(true);
|
||||
}, [isDocumentVisible, isNearViewport]);
|
||||
|
||||
return { plotRef, shouldMountPlot };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { IconChevronDown, IconChevronRight, IconExternalLink, IconFile } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { getFileCategory, getImageMimeType } from "@/lib/utils/file-types";
|
||||
import {
|
||||
useWorkspaceFilePreview,
|
||||
type WorkspaceFilePreviewState,
|
||||
} from "@/hooks/domains/session/use-workspace-file-preview";
|
||||
import type { RichOutputFileBlock } from "./types";
|
||||
|
||||
const TEXT_PREVIEW_CHARS = 2000;
|
||||
|
||||
function FilePreviewContent({
|
||||
block,
|
||||
state,
|
||||
}: {
|
||||
block: RichOutputFileBlock;
|
||||
state: WorkspaceFilePreviewState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<GridSpinner />
|
||||
<span>{t("task:richOutputLoadingPreview")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error" || state.kind === "idle") {
|
||||
return (
|
||||
<p className="py-4 text-xs text-muted-foreground">{t("task:richOutputPreviewFailed")}</p>
|
||||
);
|
||||
}
|
||||
if (state.response.is_binary) {
|
||||
if (getFileCategory(block.path) !== "image") {
|
||||
return (
|
||||
<p className="py-4 text-xs text-muted-foreground">
|
||||
{t("task:richOutputBinaryPreviewUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-center py-2">
|
||||
<img
|
||||
src={`data:${getImageMimeType(block.path)};base64,${state.response.content}`}
|
||||
alt={block.title || block.path}
|
||||
className="max-h-64 max-w-full rounded-md object-contain ring-1 ring-black/10 dark:ring-white/10"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const clipped = state.response.content.slice(0, TEXT_PREVIEW_CHARS);
|
||||
const wasClipped = state.response.content.length > TEXT_PREVIEW_CHARS;
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<pre className="max-h-52 max-w-full overflow-y-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-relaxed text-foreground">
|
||||
{clipped}
|
||||
</pre>
|
||||
{wasClipped && (
|
||||
<p className="text-[10px] text-muted-foreground">{t("task:richOutputPreviewTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilePreviewBlockProps = {
|
||||
block: RichOutputFileBlock;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
};
|
||||
|
||||
export function FilePreviewBlock({ block, sessionId, onOpenFile }: FilePreviewBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const previewId = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { load, state } = useWorkspaceFilePreview(sessionId, block.path, block.repo);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
if (next && (state.kind === "idle" || state.kind === "error")) void load();
|
||||
}, [expanded, load, state.kind]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="min-w-0 rounded-lg border border-border/50 bg-background/50"
|
||||
data-testid="rich-output-file"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-2 px-3 py-2 min-[420px]:flex-row min-[420px]:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<IconFile className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
{block.title && <p className="truncate text-xs font-medium">{block.title}</p>}
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground" title={block.path}>
|
||||
{block.repo ? `${block.repo}:` : ""}
|
||||
{block.path}
|
||||
</p>
|
||||
{block.caption && (
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{block.caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 self-end min-[420px]:self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={previewId}
|
||||
onClick={handleToggle}
|
||||
data-testid="rich-output-file-preview-toggle"
|
||||
>
|
||||
{expanded ? (
|
||||
<IconChevronDown className="size-4" />
|
||||
) : (
|
||||
<IconChevronRight className="size-4" />
|
||||
)}
|
||||
{expanded ? t("task:richOutputHidePreview") : t("task:richOutputPreview")}
|
||||
</Button>
|
||||
{onOpenFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 cursor-pointer gap-1.5 px-2.5 min-[640px]:min-h-9"
|
||||
onClick={() => onOpenFile(block.path, block.repo)}
|
||||
data-testid="rich-output-file-open"
|
||||
>
|
||||
<IconExternalLink className="size-4" />
|
||||
{t("task:richOutputOpenFile")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div id={previewId} className="border-t border-border/50 px-3 py-2">
|
||||
<FilePreviewContent block={block} state={state} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RichOutputMetricsBlock } from "./types";
|
||||
|
||||
export function MetricsBlock({ block }: { block: RichOutputMetricsBlock }) {
|
||||
return (
|
||||
<dl
|
||||
className="grid min-w-0 grid-cols-1 gap-x-5 gap-y-3 min-[420px]:grid-cols-2 sm:grid-cols-3"
|
||||
data-testid="rich-output-metrics"
|
||||
>
|
||||
{block.items.map((item, index) => (
|
||||
<div className="min-w-0 space-y-0.5" key={`${item.label}-${index}`}>
|
||||
<dt className="truncate text-[11px] font-medium text-muted-foreground">{item.label}</dt>
|
||||
<dd className="break-words font-mono text-xl font-medium leading-tight tabular-nums text-foreground">
|
||||
{item.value}
|
||||
</dd>
|
||||
{item.detail && (
|
||||
<dd className="text-[11px] leading-relaxed text-muted-foreground">{item.detail}</dd>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRichOutput } from "./parse";
|
||||
|
||||
function validPayload(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
title: "Build health",
|
||||
description: "Latest local verification",
|
||||
blocks: [
|
||||
{
|
||||
type: "metrics",
|
||||
items: [
|
||||
{ label: "Passed", value: "38", detail: "All focused checks" },
|
||||
{ label: "Failed", value: "0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Runtime by run",
|
||||
summary: "Runtime fell across the last three runs.",
|
||||
labels: ["1", "2", "3"],
|
||||
series: [{ label: "Seconds", values: [18.2, 16.4, null] }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
path: "reports/build.json",
|
||||
repo: "kandev",
|
||||
title: "Raw report",
|
||||
caption: "Generated by verification",
|
||||
mime_type: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function csvPayload(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
title: "API health",
|
||||
blocks: [
|
||||
{ type: "metrics", items: [{ label: "Requests", value: "2.4M" }] },
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Latency over time",
|
||||
summary: "p95 latency fell over two days.",
|
||||
csv: {
|
||||
path: "reports/latency.csv",
|
||||
repo: "api",
|
||||
x_column: "recorded_at",
|
||||
series: [{ column: "p95_ms", label: "p95" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function csvSnapshot(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
resolved_charts: [
|
||||
{
|
||||
block_index: 1,
|
||||
labels: ["2026-08-13T10:00:00Z", "2026-08-14T10:00:00Z"],
|
||||
series: [{ label: "p95", values: [29.4, null] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseRichOutput", () => {
|
||||
it("accepts the complete version 1 union", () => {
|
||||
expect(parseRichOutput(validPayload())).toEqual(validPayload());
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRichOutput CSV charts", () => {
|
||||
it("resolves a CSV chart only from its persisted normalized snapshot", () => {
|
||||
expect(parseRichOutput(csvPayload(), csvSnapshot())).toEqual({
|
||||
version: 1,
|
||||
title: "API health",
|
||||
blocks: [
|
||||
{ type: "metrics", items: [{ label: "Requests", value: "2.4M" }] },
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Latency over time",
|
||||
summary: "p95 latency fell over two days.",
|
||||
labels: ["2026-08-13T10:00:00Z", "2026-08-14T10:00:00Z"],
|
||||
series: [{ label: "p95", values: [29.4, null] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parseRichOutput(csvPayload())).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wrong snapshot version", { ...csvSnapshot(), version: 2 }],
|
||||
["missing chart", { version: 1, resolved_charts: [] }],
|
||||
[
|
||||
"duplicate chart index",
|
||||
{
|
||||
version: 1,
|
||||
resolved_charts: [
|
||||
...(csvSnapshot().resolved_charts as unknown[]),
|
||||
...(csvSnapshot().resolved_charts as unknown[]),
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"out-of-range chart index",
|
||||
{
|
||||
version: 1,
|
||||
resolved_charts: [
|
||||
{ block_index: 3, labels: ["day"], series: [{ label: "p95", values: [20] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"mismatched values",
|
||||
{
|
||||
version: 1,
|
||||
resolved_charts: [
|
||||
{ block_index: 1, labels: ["one", "two"], series: [{ label: "p95", values: [20] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
["unknown snapshot field", { ...csvSnapshot(), source_content: "recorded_at,p95_ms" }],
|
||||
])("rejects CSV input with %s", (_name, snapshot) => {
|
||||
expect(parseRichOutput(csvPayload(), snapshot)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed CSV descriptors before using a snapshot", () => {
|
||||
const payload = csvPayload();
|
||||
const chart = (payload.blocks as Array<Record<string, unknown>>)[1];
|
||||
chart.csv = { path: "../latency.csv", x_column: "date", series: [] };
|
||||
expect(parseRichOutput(payload, csvSnapshot())).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a CSV snapshot that targets an inline-only presentation", () => {
|
||||
expect(parseRichOutput(validPayload(), csvSnapshot())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRichOutput validation", () => {
|
||||
it.each([
|
||||
["missing payload", undefined],
|
||||
["unsupported version", { ...validPayload(), version: 2 }],
|
||||
["unknown top-level field", { ...validPayload(), theme: "neon" }],
|
||||
["empty title", { ...validPayload(), title: "" }],
|
||||
["no blocks", { ...validPayload(), blocks: [] }],
|
||||
["unknown block", { ...validPayload(), blocks: [{ type: "table" }] }],
|
||||
])("rejects %s", (_name, payload) => {
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown nested fields", () => {
|
||||
const payload = validPayload();
|
||||
payload.blocks = [{ type: "file", path: "report.txt", color: "red" }];
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/etc/passwd",
|
||||
"../secrets.txt",
|
||||
"reports/../../secrets.txt",
|
||||
"https://example.com/report.json",
|
||||
"data:text/plain,secret",
|
||||
String.raw`C:\Users\person\secret.txt`,
|
||||
String.raw`\\server\share\secret.txt`,
|
||||
])("rejects unsafe file path %s", (path) => {
|
||||
const payload = validPayload();
|
||||
payload.blocks = [{ type: "file", path }];
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects mismatched chart values", () => {
|
||||
const payload = validPayload();
|
||||
payload.blocks = [
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "bar",
|
||||
title: "Runs",
|
||||
summary: "Run history",
|
||||
labels: ["1", "2"],
|
||||
series: [{ label: "Seconds", values: [18] }],
|
||||
},
|
||||
];
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-finite chart values", () => {
|
||||
const payload = validPayload();
|
||||
payload.blocks = [
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Runs",
|
||||
summary: "Run history",
|
||||
labels: ["1"],
|
||||
series: [{ label: "Seconds", values: [Number.POSITIVE_INFINITY] }],
|
||||
},
|
||||
];
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces collection limits", () => {
|
||||
const tooManyBlocks = validPayload();
|
||||
tooManyBlocks.blocks = Array.from({ length: 5 }, () => ({
|
||||
type: "metrics",
|
||||
items: [{ label: "Passed", value: "1" }],
|
||||
}));
|
||||
expect(parseRichOutput(tooManyBlocks)).toBeNull();
|
||||
|
||||
const tooManyMetrics = validPayload();
|
||||
tooManyMetrics.blocks = [
|
||||
{
|
||||
type: "metrics",
|
||||
items: Array.from({ length: 7 }, () => ({ label: "Metric", value: "1" })),
|
||||
},
|
||||
];
|
||||
expect(parseRichOutput(tooManyMetrics)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payloads larger than 64 KiB", () => {
|
||||
const payload = validPayload();
|
||||
payload.description = "a".repeat(66 * 1024);
|
||||
expect(parseRichOutput(payload)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
RichOutput,
|
||||
RichOutputBlock,
|
||||
RichOutputChartBlock,
|
||||
RichOutputChartSeries,
|
||||
RichOutputCSVChartBlock,
|
||||
RichOutputCSVSeries,
|
||||
RichOutputCSVSource,
|
||||
RichOutputFileBlock,
|
||||
RichOutputInput,
|
||||
RichOutputInputBlock,
|
||||
RichOutputMetric,
|
||||
RichOutputMetricsBlock,
|
||||
} from "./types";
|
||||
|
||||
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
||||
return Object.keys(value).every((key) => allowed.includes(key));
|
||||
}
|
||||
|
||||
function isBoundedString(value: unknown, min: number, max: number): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const length = Array.from(value).length;
|
||||
return length >= min && length <= max;
|
||||
}
|
||||
|
||||
function optionalBoundedString(
|
||||
value: Record<string, unknown>,
|
||||
key: string,
|
||||
max: number,
|
||||
): string | null | undefined {
|
||||
if (!Object.hasOwn(value, key)) return undefined;
|
||||
return isBoundedString(value[key], 0, max) ? value[key] : null;
|
||||
}
|
||||
|
||||
function isWorkspaceRelativePath(value: string): boolean {
|
||||
if (value.trim() === "" || value.includes("\0")) return false;
|
||||
const normalized = value.replaceAll("\\", "/");
|
||||
const lower = normalized.toLowerCase();
|
||||
if (normalized.startsWith("/") || lower.includes("://") || lower.startsWith("data:")) {
|
||||
return false;
|
||||
}
|
||||
if (/^[a-z]:/i.test(normalized)) return false;
|
||||
if (normalized.split("/").includes("..")) return false;
|
||||
return normalized.split("/").some((segment) => segment !== "" && segment !== ".");
|
||||
}
|
||||
|
||||
function parseFileBlock(value: Record<string, unknown>): RichOutputFileBlock | null {
|
||||
const allowed = ["type", "path", "repo", "title", "caption", "mime_type"];
|
||||
if (!hasOnlyKeys(value, allowed) || !isBoundedString(value.path, 1, 1024)) return null;
|
||||
if (!isWorkspaceRelativePath(value.path)) return null;
|
||||
const repo = optionalBoundedString(value, "repo", 255);
|
||||
const title = optionalBoundedString(value, "title", 120);
|
||||
const caption = optionalBoundedString(value, "caption", 500);
|
||||
const mimeType = optionalBoundedString(value, "mime_type", 128);
|
||||
if (repo === null || title === null || caption === null || mimeType === null) return null;
|
||||
return {
|
||||
type: "file",
|
||||
path: value.path,
|
||||
...(repo !== undefined && { repo }),
|
||||
...(title !== undefined && { title }),
|
||||
...(caption !== undefined && { caption }),
|
||||
...(mimeType !== undefined && { mime_type: mimeType }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseChartSeries(value: unknown, labelCount: number): RichOutputChartSeries | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ["label", "values"])) return null;
|
||||
if (!isBoundedString(value.label, 1, 120) || !Array.isArray(value.values)) return null;
|
||||
if (value.values.length < 1 || value.values.length > 100 || value.values.length !== labelCount) {
|
||||
return null;
|
||||
}
|
||||
const values: Array<number | null> = [];
|
||||
for (const item of value.values) {
|
||||
if (item === null) values.push(null);
|
||||
else if (typeof item === "number" && Number.isFinite(item)) values.push(item);
|
||||
else return null;
|
||||
}
|
||||
return { label: value.label, values };
|
||||
}
|
||||
|
||||
function hasValidChartHeader(value: Record<string, unknown>): boolean {
|
||||
if (value.chart_type !== "line" && value.chart_type !== "bar") return false;
|
||||
return isBoundedString(value.title, 1, 120) && isBoundedString(value.summary, 1, 500);
|
||||
}
|
||||
|
||||
function parseInlineChartBlock(value: Record<string, unknown>): RichOutputChartBlock | null {
|
||||
if (!Array.isArray(value.labels) || value.labels.length < 1 || value.labels.length > 100) {
|
||||
return null;
|
||||
}
|
||||
const labels = value.labels;
|
||||
if (!labels.every((label) => isBoundedString(label, 1, 120))) return null;
|
||||
if (!Array.isArray(value.series) || value.series.length < 1 || value.series.length > 4) {
|
||||
return null;
|
||||
}
|
||||
const series = value.series.map((item) => parseChartSeries(item, labels.length));
|
||||
if (series.some((item) => item === null)) return null;
|
||||
return {
|
||||
type: "chart",
|
||||
chart_type: value.chart_type as "line" | "bar",
|
||||
title: value.title as string,
|
||||
summary: value.summary as string,
|
||||
labels,
|
||||
series: series as RichOutputChartSeries[],
|
||||
};
|
||||
}
|
||||
|
||||
function parseCSVSeries(value: unknown): RichOutputCSVSeries | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ["column", "label"])) return null;
|
||||
if (!isBoundedString(value.column, 1, 120)) return null;
|
||||
const label = optionalBoundedString(value, "label", 120);
|
||||
if (label === null || label === "") return null;
|
||||
return { column: value.column, ...(label !== undefined && { label }) };
|
||||
}
|
||||
|
||||
function parseCSVSource(value: unknown): RichOutputCSVSource | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ["path", "repo", "x_column", "series"])) {
|
||||
return null;
|
||||
}
|
||||
if (!isBoundedString(value.path, 1, 1024) || !isWorkspaceRelativePath(value.path)) return null;
|
||||
if (!value.path.toLowerCase().endsWith(".csv")) return null;
|
||||
if (!isBoundedString(value.x_column, 1, 120) || !Array.isArray(value.series)) return null;
|
||||
if (value.series.length < 1 || value.series.length > 4) return null;
|
||||
const repo = optionalBoundedString(value, "repo", 255);
|
||||
const series = value.series.map(parseCSVSeries);
|
||||
if (repo === null || series.some((item) => item === null)) return null;
|
||||
return {
|
||||
path: value.path,
|
||||
...(repo !== undefined && { repo }),
|
||||
x_column: value.x_column,
|
||||
series: series as RichOutputCSVSeries[],
|
||||
};
|
||||
}
|
||||
|
||||
function parseCSVChartBlock(value: Record<string, unknown>): RichOutputCSVChartBlock | null {
|
||||
const csv = parseCSVSource(value.csv);
|
||||
if (!csv) return null;
|
||||
return {
|
||||
type: "chart",
|
||||
chart_type: value.chart_type as "line" | "bar",
|
||||
title: value.title as string,
|
||||
summary: value.summary as string,
|
||||
csv,
|
||||
};
|
||||
}
|
||||
|
||||
function parseChartBlock(
|
||||
value: Record<string, unknown>,
|
||||
): RichOutputChartBlock | RichOutputCSVChartBlock | null {
|
||||
const allowed = ["type", "chart_type", "title", "summary", "labels", "series", "csv"];
|
||||
if (!hasOnlyKeys(value, allowed) || !hasValidChartHeader(value)) return null;
|
||||
const hasInline = Object.hasOwn(value, "labels") || Object.hasOwn(value, "series");
|
||||
const hasCSV = Object.hasOwn(value, "csv");
|
||||
if (hasInline === hasCSV) return null;
|
||||
return hasCSV ? parseCSVChartBlock(value) : parseInlineChartBlock(value);
|
||||
}
|
||||
|
||||
function parseMetric(value: unknown): RichOutputMetric | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ["label", "value", "detail"])) return null;
|
||||
if (!isBoundedString(value.label, 1, 120) || !isBoundedString(value.value, 1, 200)) return null;
|
||||
const detail = optionalBoundedString(value, "detail", 500);
|
||||
if (detail === null) return null;
|
||||
return { label: value.label, value: value.value, ...(detail !== undefined && { detail }) };
|
||||
}
|
||||
|
||||
function parseMetricsBlock(value: Record<string, unknown>): RichOutputMetricsBlock | null {
|
||||
if (!hasOnlyKeys(value, ["type", "items"]) || !Array.isArray(value.items)) return null;
|
||||
if (value.items.length < 1 || value.items.length > 6) return null;
|
||||
const items = value.items.map(parseMetric);
|
||||
if (items.some((item) => item === null)) return null;
|
||||
return { type: "metrics", items: items as RichOutputMetric[] };
|
||||
}
|
||||
|
||||
function parseBlock(value: unknown): RichOutputInputBlock | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.type === "file") return parseFileBlock(value);
|
||||
if (value.type === "chart") return parseChartBlock(value);
|
||||
if (value.type === "metrics") return parseMetricsBlock(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function serializedByteLength(value: unknown): number | null {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? null : new TextEncoder().encode(serialized).byteLength;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRichOutputInput(value: unknown): RichOutputInput | null {
|
||||
const bytes = serializedByteLength(value);
|
||||
if (bytes === null || bytes > MAX_PAYLOAD_BYTES || !isRecord(value)) return null;
|
||||
if (!hasOnlyKeys(value, ["version", "title", "description", "blocks"])) return null;
|
||||
if (value.version !== 1 || !isBoundedString(value.title, 1, 120)) return null;
|
||||
const description = optionalBoundedString(value, "description", 500);
|
||||
if (description === null || !Array.isArray(value.blocks)) return null;
|
||||
if (value.blocks.length < 1 || value.blocks.length > 4) return null;
|
||||
const blocks = value.blocks.map(parseBlock);
|
||||
if (blocks.some((block) => block === null)) return null;
|
||||
return {
|
||||
version: 1,
|
||||
title: value.title,
|
||||
...(description !== undefined && { description }),
|
||||
blocks: blocks as RichOutputInputBlock[],
|
||||
};
|
||||
}
|
||||
|
||||
type ResolvedChartSnapshot = {
|
||||
block_index: number;
|
||||
labels: string[];
|
||||
series: RichOutputChartSeries[];
|
||||
};
|
||||
|
||||
type RichOutputSnapshot = {
|
||||
version: 1;
|
||||
resolved_charts: ResolvedChartSnapshot[];
|
||||
};
|
||||
|
||||
function parseResolvedChart(value: unknown): ResolvedChartSnapshot | null {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ["block_index", "labels", "series"])) return null;
|
||||
if (!Number.isInteger(value.block_index) || (value.block_index as number) < 0) return null;
|
||||
if ((value.block_index as number) > 3) return null;
|
||||
if (!Array.isArray(value.labels) || value.labels.length < 1 || value.labels.length > 100) {
|
||||
return null;
|
||||
}
|
||||
const labels = value.labels;
|
||||
if (!labels.every((label) => isBoundedString(label, 1, 120))) return null;
|
||||
if (!Array.isArray(value.series) || value.series.length < 1 || value.series.length > 4) {
|
||||
return null;
|
||||
}
|
||||
const series = value.series.map((item) => parseChartSeries(item, labels.length));
|
||||
if (series.some((item) => item === null)) return null;
|
||||
return {
|
||||
block_index: value.block_index as number,
|
||||
labels,
|
||||
series: series as RichOutputChartSeries[],
|
||||
};
|
||||
}
|
||||
|
||||
function parseRichOutputSnapshot(value: unknown): RichOutputSnapshot | null {
|
||||
const bytes = serializedByteLength(value);
|
||||
if (bytes === null || bytes > MAX_PAYLOAD_BYTES || !isRecord(value)) return null;
|
||||
if (!hasOnlyKeys(value, ["version", "resolved_charts"]) || value.version !== 1) return null;
|
||||
if (!Array.isArray(value.resolved_charts)) return null;
|
||||
if (value.resolved_charts.length < 1 || value.resolved_charts.length > 4) return null;
|
||||
const charts = value.resolved_charts.map(parseResolvedChart);
|
||||
if (charts.some((chart) => chart === null)) return null;
|
||||
return { version: 1, resolved_charts: charts as ResolvedChartSnapshot[] };
|
||||
}
|
||||
|
||||
function expectedCSVSeriesLabels(block: RichOutputCSVChartBlock): string[] {
|
||||
return block.csv.series.map((series) => series.label ?? series.column);
|
||||
}
|
||||
|
||||
function resolveCSVChart(
|
||||
block: RichOutputCSVChartBlock,
|
||||
snapshot: ResolvedChartSnapshot,
|
||||
): RichOutputChartBlock | null {
|
||||
const expectedLabels = expectedCSVSeriesLabels(block);
|
||||
if (snapshot.series.length !== expectedLabels.length) return null;
|
||||
if (!snapshot.series.every((series, index) => series.label === expectedLabels[index]))
|
||||
return null;
|
||||
return {
|
||||
type: "chart",
|
||||
chart_type: block.chart_type,
|
||||
title: block.title,
|
||||
summary: block.summary,
|
||||
labels: snapshot.labels,
|
||||
series: snapshot.series,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCSVCharts(input: RichOutputInput, result: unknown): RichOutput | null {
|
||||
const csvIndexes = new Set<number>();
|
||||
input.blocks.forEach((block, index) => {
|
||||
if (block.type === "chart" && "csv" in block) csvIndexes.add(index);
|
||||
});
|
||||
if (csvIndexes.size === 0) {
|
||||
return parseRichOutputSnapshot(result) ? null : (input as RichOutput);
|
||||
}
|
||||
const snapshot = parseRichOutputSnapshot(result);
|
||||
if (!snapshot || snapshot.resolved_charts.length !== csvIndexes.size) return null;
|
||||
const byIndex = new Map<number, ResolvedChartSnapshot>();
|
||||
for (const chart of snapshot.resolved_charts) {
|
||||
if (!csvIndexes.has(chart.block_index) || byIndex.has(chart.block_index)) return null;
|
||||
byIndex.set(chart.block_index, chart);
|
||||
}
|
||||
const blocks: RichOutputBlock[] = [];
|
||||
for (let index = 0; index < input.blocks.length; index += 1) {
|
||||
const block = input.blocks[index];
|
||||
if (block.type !== "chart" || !("csv" in block)) {
|
||||
blocks.push(block as RichOutputBlock);
|
||||
continue;
|
||||
}
|
||||
const chartSnapshot = byIndex.get(index);
|
||||
if (!chartSnapshot) return null;
|
||||
const resolved = resolveCSVChart(block, chartSnapshot);
|
||||
if (!resolved) return null;
|
||||
blocks.push(resolved);
|
||||
}
|
||||
return { ...input, blocks };
|
||||
}
|
||||
|
||||
export function parseRichOutput(value: unknown, result?: unknown): RichOutput | null {
|
||||
const input = parseRichOutputInput(value);
|
||||
return input ? resolveCSVCharts(input, result) : null;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { KandevRendererProps } from "../types";
|
||||
|
||||
const parseRichOutput = vi.hoisted(() =>
|
||||
vi.fn(() => ({ version: 1 as const, title: "Stable result", blocks: [] })),
|
||||
);
|
||||
|
||||
vi.mock("./parse", () => ({ parseRichOutput }));
|
||||
|
||||
import { RichOutputRenderer } from "./rich-output-renderer";
|
||||
|
||||
function RendererHarness(props: KandevRendererProps) {
|
||||
return RichOutputRenderer(props);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
parseRichOutput.mockClear();
|
||||
});
|
||||
|
||||
describe("RichOutputRenderer", () => {
|
||||
it("reuses parsed output while persisted arguments and result stay unchanged", () => {
|
||||
const args = { version: 1, title: "Stable result", blocks: [] };
|
||||
const result = { version: 1 };
|
||||
const props: KandevRendererProps = { args, result, status: "complete" };
|
||||
const view = render(<RendererHarness {...props} />);
|
||||
|
||||
view.rerender(<RendererHarness {...props} />);
|
||||
|
||||
expect(parseRichOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { IconChartBar } from "@tabler/icons-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KandevRow } from "../shared";
|
||||
import type { KandevRenderer } from "../types";
|
||||
import { ChartBlock } from "./chart-block";
|
||||
import { FilePreviewBlock } from "./file-preview-block";
|
||||
import { MetricsBlock } from "./metrics-block";
|
||||
import { parseRichOutput } from "./parse";
|
||||
import type { RichOutputBlock } from "./types";
|
||||
|
||||
function RichOutputBlockView({
|
||||
block,
|
||||
sessionId,
|
||||
onOpenFile,
|
||||
}: {
|
||||
block: RichOutputBlock;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
}) {
|
||||
if (block.type === "metrics") return <MetricsBlock block={block} />;
|
||||
if (block.type === "chart") return <ChartBlock block={block} />;
|
||||
return <FilePreviewBlock block={block} sessionId={sessionId} onOpenFile={onOpenFile} />;
|
||||
}
|
||||
|
||||
export const RichOutputRenderer: KandevRenderer = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
sessionId,
|
||||
onOpenFile,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const output = useMemo(() => parseRichOutput(args, result), [args, result]);
|
||||
|
||||
if (status !== "complete") {
|
||||
return (
|
||||
<KandevRow
|
||||
Icon={IconChartBar}
|
||||
title={t("task:richOutputTool")}
|
||||
status={status}
|
||||
hasExpandableContent={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="my-2 max-w-full overflow-hidden rounded-xl border border-border/60 bg-background/80 px-4 py-3.5"
|
||||
data-testid="rich-output"
|
||||
aria-label={output?.title || t("task:richOutputTool")}
|
||||
>
|
||||
{output ? (
|
||||
<>
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium tracking-tight text-foreground">{output.title}</h3>
|
||||
{output.description && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{output.description}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="mt-4 divide-y divide-border/50 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
||||
{output.blocks.map((block, index) => (
|
||||
<div className="min-w-0" key={`${block.type}-${index}`}>
|
||||
<RichOutputBlockView block={block} sessionId={sessionId} onOpenFile={onOpenFile} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("task:richOutputUnavailable")}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export type RichOutputFileBlock = {
|
||||
type: "file";
|
||||
path: string;
|
||||
repo?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
mime_type?: string;
|
||||
};
|
||||
|
||||
export type RichOutputChartSeries = {
|
||||
label: string;
|
||||
values: Array<number | null>;
|
||||
};
|
||||
|
||||
export type RichOutputChartBlock = {
|
||||
type: "chart";
|
||||
chart_type: "line" | "bar";
|
||||
title: string;
|
||||
summary: string;
|
||||
labels: string[];
|
||||
series: RichOutputChartSeries[];
|
||||
};
|
||||
|
||||
export type RichOutputCSVSeries = {
|
||||
column: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type RichOutputCSVSource = {
|
||||
path: string;
|
||||
repo?: string;
|
||||
x_column: string;
|
||||
series: RichOutputCSVSeries[];
|
||||
};
|
||||
|
||||
export type RichOutputCSVChartBlock = {
|
||||
type: "chart";
|
||||
chart_type: "line" | "bar";
|
||||
title: string;
|
||||
summary: string;
|
||||
csv: RichOutputCSVSource;
|
||||
};
|
||||
|
||||
export type RichOutputMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type RichOutputMetricsBlock = {
|
||||
type: "metrics";
|
||||
items: RichOutputMetric[];
|
||||
};
|
||||
|
||||
export type RichOutputBlock = RichOutputFileBlock | RichOutputChartBlock | RichOutputMetricsBlock;
|
||||
|
||||
export type RichOutputInputBlock = RichOutputBlock | RichOutputCSVChartBlock;
|
||||
|
||||
export type RichOutputInput = {
|
||||
version: 1;
|
||||
title: string;
|
||||
description?: string;
|
||||
blocks: RichOutputInputBlock[];
|
||||
};
|
||||
|
||||
export type RichOutput = {
|
||||
version: 1;
|
||||
title: string;
|
||||
description?: string;
|
||||
blocks: RichOutputBlock[];
|
||||
};
|
||||
@@ -12,6 +12,8 @@ export type KandevRendererProps = {
|
||||
args: Record<string, unknown> | undefined;
|
||||
result: unknown;
|
||||
status: KandevStatus;
|
||||
sessionId?: string;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
};
|
||||
|
||||
export type KandevRenderer = (props: KandevRendererProps) => ReactElement;
|
||||
|
||||
@@ -18,7 +18,7 @@ type TurnGroupMessageProps = {
|
||||
childrenByParentToolCallId?: Map<string, Message[]>;
|
||||
taskId?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
/** Whether this is the last turn group in the current turn */
|
||||
isLastGroup?: boolean;
|
||||
/** Whether the turn is still active (agent is running) */
|
||||
@@ -131,7 +131,7 @@ type TurnGroupContentProps = {
|
||||
childrenByParentToolCallId?: Map<string, Message[]>;
|
||||
taskId?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
isTurnActive?: boolean;
|
||||
streamingMessageId?: string | null;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
|
||||
import { isRichOutputMessage, kandevToolStemOf, type ToolCallMetadata } from "./types";
|
||||
|
||||
function toolCall(rawName: string, field: "tool_name" | "title" | "content" = "title"): Message {
|
||||
const metadata: Record<string, unknown> = {
|
||||
status: "complete",
|
||||
normalized: { kind: "generic", generic: { name: "other" } },
|
||||
};
|
||||
if (field !== "content") metadata[field] = rawName;
|
||||
return {
|
||||
id: "message-1",
|
||||
session_id: toSessionId("session-1"),
|
||||
task_id: toTaskId("task-1"),
|
||||
author_type: "agent",
|
||||
content: field === "content" ? rawName : "",
|
||||
type: "tool_call",
|
||||
metadata,
|
||||
created_at: "2026-08-14T12:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("kandevToolStemOf", () => {
|
||||
it.each(["tool_name", "title", "content"] as const)("reads the %s field", (field) => {
|
||||
expect(kandevToolStemOf(toolCall("mcp__kandev__show_rich_output_kandev", field))).toBe(
|
||||
"show_rich_output",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a generic category and keeps scanning", () => {
|
||||
expect(kandevToolStemOf(toolCall("mcp__kandev__show_rich_output_kandev"))).toBe(
|
||||
"show_rich_output",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the normalized generic tool identity as a fallback", () => {
|
||||
const message = toolCall("");
|
||||
const metadata = message.metadata as ToolCallMetadata;
|
||||
metadata.normalized!.generic!.name = "show_rich_output_kandev";
|
||||
|
||||
expect(kandevToolStemOf(message)).toBe("show_rich_output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRichOutputMessage", () => {
|
||||
it("matches only the exact Kandev rich-output tool call", () => {
|
||||
expect(isRichOutputMessage(toolCall("mcp__kandev__show_rich_output_kandev"))).toBe(true);
|
||||
expect(isRichOutputMessage(toolCall("kandev/show_rich_output_kandev"))).toBe(true);
|
||||
expect(isRichOutputMessage(toolCall("mcp__kandev__show_walkthrough_kandev"))).toBe(false);
|
||||
expect(isRichOutputMessage(toolCall("other/show_rich_output_kandev"))).toBe(false);
|
||||
expect(isRichOutputMessage(toolCall("mcp__other__show_rich_output"))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a namespaced normalized identity as authoritative", () => {
|
||||
const message = toolCall("mcp.kandev.show_rich_output_kandev");
|
||||
const metadata = message.metadata as ToolCallMetadata;
|
||||
metadata.normalized!.generic!.name = "other/show_rich_output_kandev";
|
||||
|
||||
expect(isRichOutputMessage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-tool messages", () => {
|
||||
expect(isRichOutputMessage({ ...toolCall("show_rich_output_kandev"), type: "message" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { extractKandevStem } from "./messages/kandev/parse";
|
||||
|
||||
export type SubagentTaskPayload = {
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
@@ -158,6 +161,32 @@ export type ToolCallMetadata = {
|
||||
normalized?: NormalizedPayload;
|
||||
};
|
||||
|
||||
// Tool names are duplicated across transport fields. Keep one scanner so
|
||||
// renderer dispatch and transcript grouping cannot disagree.
|
||||
export function kandevToolStemOf(message: Message): string | null {
|
||||
const metadata = message.metadata as ToolCallMetadata | undefined;
|
||||
const normalizedName = metadata?.normalized?.generic?.name;
|
||||
const normalizedStem = extractKandevStem(normalizedName);
|
||||
if (normalizedStem) return normalizedStem;
|
||||
if (normalizedName && /\/|__|\./.test(normalizedName)) return null;
|
||||
const candidates = [metadata?.tool_name, metadata?.title, message.content || undefined];
|
||||
for (const candidate of candidates) {
|
||||
const stem = extractKandevStem(candidate);
|
||||
if (stem) return stem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRichOutputMessage(message: Message): boolean {
|
||||
return message.type === "tool_call" && kandevToolStemOf(message) === "show_rich_output";
|
||||
}
|
||||
|
||||
/** A subagent is another agent's turn, so it remains standalone in the transcript. */
|
||||
export function isSubagentMessage(message: Message): boolean {
|
||||
const metadata = message.metadata as ToolCallMetadata | undefined;
|
||||
return metadata?.normalized?.kind === "subagent_task";
|
||||
}
|
||||
|
||||
export type StatusMetadata = {
|
||||
progress?: number;
|
||||
status?: string;
|
||||
|
||||
@@ -340,7 +340,7 @@ type ChatContextItemsOptions = {
|
||||
unpinFile: (sid: string, path: string) => void;
|
||||
comments: CommentsState;
|
||||
taskId: string | null;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
onOpenFileAtLine?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
@@ -510,7 +510,7 @@ function deriveQueueAwareSessionInput(
|
||||
export type UseChatPanelStateOptions = {
|
||||
sessionId: string | null;
|
||||
taskId?: string | null;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
onOpenFileAtLine?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ function useFileTabOperations({
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
async (filePath: string) => {
|
||||
async (filePath: string, repo?: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !activeSessionId) return;
|
||||
try {
|
||||
@@ -160,11 +160,13 @@ function useFileTabOperations({
|
||||
client,
|
||||
activeSessionId,
|
||||
filePath,
|
||||
repo,
|
||||
);
|
||||
const fileName = filePath.split("/").pop() || filePath;
|
||||
const hash = await calculateHash(response.content);
|
||||
addFileTab({
|
||||
path: filePath,
|
||||
repo,
|
||||
name: fileName,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
|
||||
@@ -214,7 +214,7 @@ type TaskChatPanelProps = {
|
||||
* it also drives plan mode, the composer, and read tracking.
|
||||
*/
|
||||
statusTaskId?: string | null;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onOpenFile?: (path: string, repo?: string) => void;
|
||||
showRequestChangesTooltip?: boolean;
|
||||
onRequestChangesTooltipDismiss?: () => void;
|
||||
/** Callback to open a file at a specific line (for comment clicks) */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { RICH_OUTPUT_FILE_CONTENT, seedRichOutputTask } from "./rich-output-helpers";
|
||||
|
||||
test("mobile rich output stays contained and opens the native file viewer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "mobile-chrome", "mobile-only rich output layout");
|
||||
const session = await seedRichOutputTask({
|
||||
page: testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
title: "Mobile rich output",
|
||||
});
|
||||
const richOutput = session.activeChat().getByTestId("rich-output");
|
||||
const charts = richOutput.locator('figure[data-testid^="rich-output-chart-"]');
|
||||
|
||||
await expect(richOutput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(charts).toHaveCount(2);
|
||||
const lineChart = richOutput.getByTestId("rich-output-chart-line");
|
||||
const barChart = richOutput.getByTestId("rich-output-chart-bar");
|
||||
await expect(lineChart).toBeVisible();
|
||||
await expect(barChart).toBeVisible();
|
||||
await lineChart.scrollIntoViewIfNeeded();
|
||||
await expect(lineChart.locator(".recharts-xAxis text").first()).toBeVisible();
|
||||
await expect(lineChart.locator(".recharts-yAxis text").first()).toBeVisible();
|
||||
await barChart.scrollIntoViewIfNeeded();
|
||||
await expect(barChart.locator(".recharts-xAxis text").first()).toBeVisible();
|
||||
await expect(barChart.locator(".recharts-yAxis text").first()).toBeVisible();
|
||||
await expect(lineChart.getByTestId("rich-output-chart-legend-series_0")).toContainText("p95");
|
||||
|
||||
const errorsLegend = barChart.getByRole("button", { name: "Errors" });
|
||||
const visibleBars = barChart.locator(".recharts-bar-rectangle");
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleBars).toHaveCount(6);
|
||||
await errorsLegend.tap();
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleBars).toHaveCount(3);
|
||||
await errorsLegend.tap();
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleBars).toHaveCount(6);
|
||||
await expect
|
||||
.poll(() =>
|
||||
testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const cardBox = await richOutput.boundingBox();
|
||||
expect(cardBox).not.toBeNull();
|
||||
for (const chart of await charts.all()) {
|
||||
const chartBox = await chart.boundingBox();
|
||||
expect(chartBox).not.toBeNull();
|
||||
expect(chartBox!.x).toBeGreaterThanOrEqual(cardBox!.x);
|
||||
expect(chartBox!.x + chartBox!.width).toBeLessThanOrEqual(cardBox!.x + cardBox!.width + 1);
|
||||
}
|
||||
|
||||
for (const button of await richOutput.getByRole("button").all()) {
|
||||
expect(await button.evaluate((element) => element.offsetHeight)).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
await richOutput.getByTestId("rich-output-file-preview-toggle").tap();
|
||||
await expect(richOutput).toContainText(RICH_OUTPUT_FILE_CONTENT.trim(), { timeout: 10_000 });
|
||||
await richOutput.getByTestId("rich-output-file-open").tap();
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(viewer.getByTestId("mobile-file-viewer-content")).toContainText("checks=38");
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
export const RICH_OUTPUT_FILE = "rich-output-report.txt";
|
||||
export const RICH_OUTPUT_FILE_CONTENT = "RICH_OUTPUT_FILE_PREVIEW\nchecks=38\nfailures=0\n";
|
||||
const RICH_OUTPUT_CSV = "rich-output-chart-data.csv";
|
||||
const RICH_OUTPUT_CSV_CONTENT = [
|
||||
"recorded_at,route,p50_ms,p95_ms,requests,errors",
|
||||
"2026-08-12T10:00:00Z,/api,18.2,29.4,2400,12",
|
||||
"2026-08-13T10:00:00Z,/health,16.4,26.1,800,2",
|
||||
"2026-08-14T10:00:00Z,/events,15.9,24.8,1700,7",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function richOutputScript(): string {
|
||||
const payload = {
|
||||
version: 1,
|
||||
title: "Release health",
|
||||
description: "Focused verification from the current workspace.",
|
||||
blocks: [
|
||||
{
|
||||
type: "metrics",
|
||||
items: [
|
||||
{ label: "Passed", value: "38", detail: "Focused checks" },
|
||||
{ label: "Failed", value: "0" },
|
||||
{ label: "Coverage", value: "94%" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "line",
|
||||
title: "Latency over time",
|
||||
summary: "p50 and p95 latency from the workspace CSV.",
|
||||
csv: {
|
||||
path: RICH_OUTPUT_CSV,
|
||||
x_column: "recorded_at",
|
||||
series: [{ column: "p95_ms", label: "p95" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
chart_type: "bar",
|
||||
title: "Requests by route",
|
||||
summary: "Request and error counts from the same workspace CSV.",
|
||||
csv: {
|
||||
path: RICH_OUTPUT_CSV,
|
||||
x_column: "route",
|
||||
series: [
|
||||
{ column: "requests", label: "Requests" },
|
||||
{ column: "errors", label: "Errors" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
path: RICH_OUTPUT_FILE,
|
||||
title: "Raw verification report",
|
||||
caption: "Workspace-backed evidence. Loaded only when requested.",
|
||||
mime_type: "text/plain",
|
||||
},
|
||||
],
|
||||
};
|
||||
return [
|
||||
`e2e:mcp:kandev:show_rich_output_kandev(${JSON.stringify(payload)})`,
|
||||
'e2e:message("Rich output complete.")',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function seedRichOutputTask({
|
||||
page,
|
||||
apiClient,
|
||||
seedData,
|
||||
title,
|
||||
}: {
|
||||
page: Page;
|
||||
apiClient: ApiClient;
|
||||
seedData: SeedData;
|
||||
title: string;
|
||||
}): Promise<SessionPage> {
|
||||
fs.writeFileSync(path.join(seedData.repositoryPath, RICH_OUTPUT_FILE), RICH_OUTPUT_FILE_CONTENT);
|
||||
fs.writeFileSync(path.join(seedData.repositoryPath, RICH_OUTPUT_CSV), RICH_OUTPUT_CSV_CONTENT);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: richOutputScript(),
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await page.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import {
|
||||
RICH_OUTPUT_FILE,
|
||||
RICH_OUTPUT_FILE_CONTENT,
|
||||
seedRichOutputTask,
|
||||
} from "./rich-output-helpers";
|
||||
|
||||
test("renders and persists native rich output with an explicit file preview", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await seedRichOutputTask({
|
||||
page: testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
title: "Desktop rich output",
|
||||
});
|
||||
const richOutput = session.activeChat().getByTestId("rich-output");
|
||||
|
||||
await expect(richOutput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(richOutput.getByRole("heading", { name: "Release health" })).toBeVisible();
|
||||
await expect(richOutput.getByTestId("rich-output-metrics")).toContainText("38");
|
||||
const lineChart = richOutput.getByTestId("rich-output-chart-line");
|
||||
const barChart = richOutput.getByTestId("rich-output-chart-bar");
|
||||
await expect(lineChart).toBeVisible();
|
||||
await expect(barChart).toBeVisible();
|
||||
await lineChart.scrollIntoViewIfNeeded();
|
||||
await expect(lineChart.locator(".recharts-xAxis text").first()).toBeVisible();
|
||||
await expect(lineChart.locator(".recharts-line-curve")).toHaveAttribute("stroke-dasharray", /\d/);
|
||||
await expect(lineChart.locator(".recharts-yAxis text").first()).toBeVisible();
|
||||
await expect(lineChart.locator(".recharts-xAxis")).toContainText("Aug 12");
|
||||
await barChart.scrollIntoViewIfNeeded();
|
||||
await expect(barChart.locator(".recharts-xAxis text").first()).toBeVisible();
|
||||
await expect(barChart.locator(".recharts-yAxis text").first()).toBeVisible();
|
||||
await expect(barChart.locator(".recharts-xAxis")).toContainText("/api");
|
||||
|
||||
const singleSeriesLegend = lineChart.getByTestId("rich-output-chart-legend-series_0");
|
||||
await expect(singleSeriesLegend).toContainText("p95");
|
||||
await expect(singleSeriesLegend).not.toHaveAttribute("role", "button");
|
||||
|
||||
const errorsLegend = barChart.getByRole("button", { name: "Errors" });
|
||||
const visibleBars = barChart.locator(".recharts-bar-rectangle");
|
||||
await expect(barChart.getByRole("button", { name: "Requests" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleBars).toHaveCount(6);
|
||||
|
||||
await barChart.locator(".recharts-bar-rectangle").first().hover();
|
||||
const tooltip = barChart.locator(".recharts-tooltip-wrapper");
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText("/api");
|
||||
await expect(tooltip).toContainText("2,400");
|
||||
|
||||
await errorsLegend.click();
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleBars).toHaveCount(3);
|
||||
await errorsLegend.click();
|
||||
await expect(errorsLegend).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleBars).toHaveCount(6);
|
||||
await expect(richOutput.getByText("Raw verification report")).toBeVisible();
|
||||
await expect(richOutput).not.toContainText("RICH_OUTPUT_FILE_PREVIEW");
|
||||
|
||||
fs.rmSync(path.join(seedData.repositoryPath, "rich-output-chart-data.csv"), { force: true });
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(session.activeChat().getByTestId("rich-output")).toContainText("Release health", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const persisted = session.activeChat().getByTestId("rich-output");
|
||||
await expect(persisted.getByTestId("rich-output-chart-line")).toBeVisible();
|
||||
await expect(persisted.getByTestId("rich-output-chart-bar")).toBeVisible();
|
||||
await persisted.getByTestId("rich-output-chart-line").scrollIntoViewIfNeeded();
|
||||
await expect(
|
||||
persisted.getByTestId("rich-output-chart-line").locator(".recharts-xAxis text").first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
persisted
|
||||
.getByTestId("rich-output-chart-line")
|
||||
.getByTestId("rich-output-chart-legend-series_0"),
|
||||
).toContainText("p95");
|
||||
await persisted.getByTestId("rich-output-chart-bar").scrollIntoViewIfNeeded();
|
||||
await expect(persisted.getByRole("button", { name: "Errors" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await persisted.getByTestId("rich-output-file-preview-toggle").click();
|
||||
await expect(persisted).toContainText(RICH_OUTPUT_FILE_CONTENT.trim(), { timeout: 10_000 });
|
||||
|
||||
await persisted.getByTestId("rich-output-file-open").click();
|
||||
await expect(testPage.locator(`.dv-default-tab:has-text('${RICH_OUTPUT_FILE}')`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders complete chart geometry when device animation is disabled", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.addInitScript(() => {
|
||||
window.localStorage.setItem("kandev.settings.richOutputAnimations", "false");
|
||||
});
|
||||
const session = await seedRichOutputTask({
|
||||
page: testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
title: "Static desktop rich output",
|
||||
});
|
||||
const richOutput = session.activeChat().getByTestId("rich-output");
|
||||
const lineChart = richOutput.getByTestId("rich-output-chart-line");
|
||||
const barChart = richOutput.getByTestId("rich-output-chart-bar");
|
||||
|
||||
await lineChart.scrollIntoViewIfNeeded();
|
||||
const line = lineChart.locator(".recharts-line-curve");
|
||||
await expect(line).toBeVisible({ timeout: 30_000 });
|
||||
await expect(line).not.toHaveAttribute("stroke-dasharray", /\d/);
|
||||
|
||||
await barChart.scrollIntoViewIfNeeded();
|
||||
await expect(barChart.locator(".recharts-bar-rectangle")).toHaveCount(6);
|
||||
});
|
||||
@@ -1,6 +1,48 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Mobile general settings", () => {
|
||||
test("keeps the device chart-motion setting contained and persistent", async ({ testPage }) => {
|
||||
await testPage.setViewportSize({ width: 390, height: 844 });
|
||||
await testPage.addInitScript(() => {
|
||||
if (window.localStorage.getItem("kandev.settings.richOutputAnimations") === null) {
|
||||
window.localStorage.setItem("kandev.settings.richOutputAnimations", "true");
|
||||
}
|
||||
});
|
||||
await testPage.goto("/settings/preferences/appearance");
|
||||
|
||||
const card = testPage.getByTestId("rich-output-motion-settings-card");
|
||||
const row = testPage.getByTestId("rich-output-motion-toggle-row");
|
||||
const toggle = card.getByRole("switch", { name: "Animate rich-output charts" });
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await expect(toggle).toBeChecked();
|
||||
const [cardBox, rowBox, toggleBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
row.boundingBox(),
|
||||
toggle.boundingBox(),
|
||||
]);
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(toggleBox).not.toBeNull();
|
||||
expect(cardBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(cardBox!.x + cardBox!.width).toBeLessThanOrEqual(390);
|
||||
expect(rowBox!.width).toBeLessThanOrEqual(cardBox!.width);
|
||||
expect(toggleBox!.width).toBeGreaterThanOrEqual(44);
|
||||
expect(toggleBox!.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await toggle.tap();
|
||||
await testPage
|
||||
.getByTestId("settings-floating-save")
|
||||
.getByRole("button", { name: "Save changes" })
|
||||
.tap();
|
||||
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
|
||||
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
|
||||
await testPage.evaluate(() => document.documentElement.clientWidth),
|
||||
);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("keeps host sleep inhibition reachable and contained on a phone viewport", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -28,6 +28,51 @@ async function seedNotificationProvider(
|
||||
}
|
||||
|
||||
test.describe("Settings manual save", () => {
|
||||
test("persists rich-output chart motion on this device only after Save", async ({ testPage }) => {
|
||||
await testPage.addInitScript(() => {
|
||||
if (window.localStorage.getItem("kandev.settings.richOutputAnimations") === null) {
|
||||
window.localStorage.setItem("kandev.settings.richOutputAnimations", "true");
|
||||
}
|
||||
});
|
||||
let userSettingsPatches = 0;
|
||||
testPage.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "PATCH" &&
|
||||
new URL(request.url()).pathname === "/api/v1/user/settings"
|
||||
) {
|
||||
userSettingsPatches += 1;
|
||||
}
|
||||
});
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
|
||||
const toggle = testPage.getByRole("switch", { name: "Animate rich-output charts" });
|
||||
await expect(toggle).toBeChecked();
|
||||
const toggleBox = await toggle.boundingBox();
|
||||
expect(toggleBox?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(toggleBox?.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
expect(
|
||||
await testPage.evaluate(() =>
|
||||
JSON.parse(window.localStorage.getItem("kandev.settings.richOutputAnimations") ?? "null"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const floatingSave = testPage.getByTestId("settings-floating-save");
|
||||
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(floatingSave).not.toBeVisible();
|
||||
expect(userSettingsPatches).toBe(0);
|
||||
expect(
|
||||
await testPage.evaluate(() =>
|
||||
JSON.parse(window.localStorage.getItem("kandev.settings.richOutputAnimations") ?? "null"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("keeps Appearance changes local and guards dirty navigation", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWorkspaceFilePreview } from "./use-workspace-file-preview";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWebSocketClient: vi.fn(),
|
||||
requestFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ws/connection", () => ({
|
||||
getWebSocketClient: mocks.getWebSocketClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ws/workspace-files", () => ({
|
||||
requestFileContent: mocks.requestFileContent,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
describe("useWorkspaceFilePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getWebSocketClient.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("reports loading and then the loaded workspace response", async () => {
|
||||
const request = deferred<{ content: string; is_binary: boolean }>();
|
||||
mocks.requestFileContent.mockReturnValue(request.promise);
|
||||
const { result } = renderHook(() =>
|
||||
useWorkspaceFilePreview("session-1", "reports/summary.txt", "repo-a"),
|
||||
);
|
||||
|
||||
let load!: Promise<void>;
|
||||
act(() => {
|
||||
load = result.current.load();
|
||||
});
|
||||
expect(result.current.state).toEqual({ kind: "loading" });
|
||||
|
||||
request.resolve({ content: "ready", is_binary: false });
|
||||
await act(() => load);
|
||||
|
||||
expect(result.current.state).toEqual({
|
||||
kind: "loaded",
|
||||
response: { content: "ready", is_binary: false },
|
||||
});
|
||||
expect(mocks.requestFileContent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"session-1",
|
||||
"reports/summary.txt",
|
||||
"repo-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unavailable client as an error", async () => {
|
||||
mocks.getWebSocketClient.mockReturnValue(null);
|
||||
const { result } = renderHook(() =>
|
||||
useWorkspaceFilePreview("session-2", "reports/missing.txt"),
|
||||
);
|
||||
|
||||
await act(() => result.current.load());
|
||||
|
||||
expect(result.current.state).toEqual({ kind: "error" });
|
||||
expect(mocks.requestFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports request failures as an error", async () => {
|
||||
mocks.requestFileContent.mockRejectedValue(new Error("request failed"));
|
||||
const { result } = renderHook(() => useWorkspaceFilePreview("session-3", "reports/error.txt"));
|
||||
|
||||
await act(() => result.current.load());
|
||||
|
||||
expect(result.current.state).toEqual({ kind: "error" });
|
||||
});
|
||||
|
||||
it("ignores a stale response after a newer load starts", async () => {
|
||||
const staleRequest = deferred<{ content: string; is_binary: boolean }>();
|
||||
const currentRequest = deferred<{ content: string; is_binary: boolean }>();
|
||||
mocks.requestFileContent
|
||||
.mockReturnValueOnce(staleRequest.promise)
|
||||
.mockReturnValueOnce(currentRequest.promise);
|
||||
const { result } = renderHook(() =>
|
||||
useWorkspaceFilePreview("session-4", "reports/current.txt"),
|
||||
);
|
||||
|
||||
let staleLoad!: Promise<void>;
|
||||
let currentLoad!: Promise<void>;
|
||||
act(() => {
|
||||
staleLoad = result.current.load();
|
||||
currentLoad = result.current.load();
|
||||
});
|
||||
staleRequest.resolve({ content: "stale", is_binary: false });
|
||||
await act(() => staleLoad);
|
||||
expect(result.current.state).toEqual({ kind: "loading" });
|
||||
|
||||
currentRequest.resolve({ content: "current", is_binary: false });
|
||||
await act(() => currentLoad);
|
||||
expect(result.current.state).toEqual({
|
||||
kind: "loaded",
|
||||
response: { content: "current", is_binary: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FileContentResponse } from "@/lib/types/backend";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { requestFileContent } from "@/lib/ws/workspace-files";
|
||||
|
||||
export type WorkspaceFilePreviewState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error" }
|
||||
| { kind: "loaded"; response: FileContentResponse & { is_binary: boolean } };
|
||||
|
||||
export function useWorkspaceFilePreview(
|
||||
sessionId: string | undefined,
|
||||
path: string,
|
||||
repo?: string,
|
||||
) {
|
||||
const requestGeneration = useRef(0);
|
||||
const [state, setState] = useState<WorkspaceFilePreviewState>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
requestGeneration.current += 1;
|
||||
setState({ kind: "idle" });
|
||||
return () => {
|
||||
requestGeneration.current += 1;
|
||||
};
|
||||
}, [path, repo, sessionId]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const generation = (requestGeneration.current += 1);
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !sessionId) {
|
||||
setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
setState({ kind: "loading" });
|
||||
try {
|
||||
const response = await requestFileContent(client, sessionId, path, repo);
|
||||
if (requestGeneration.current === generation) setState({ kind: "loaded", response });
|
||||
} catch {
|
||||
if (requestGeneration.current === generation) setState({ kind: "error" });
|
||||
}
|
||||
}, [path, repo, sessionId]);
|
||||
|
||||
return { load, state };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
sessionId as toSessionId,
|
||||
taskId as toTaskId,
|
||||
type Message,
|
||||
type MessageType,
|
||||
} from "@/lib/types/http";
|
||||
import { buildGroupedRenderItems } from "./use-processed-messages";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
type: MessageType,
|
||||
metadata: Record<string, unknown>,
|
||||
content = "",
|
||||
): Message {
|
||||
return {
|
||||
id,
|
||||
session_id: toSessionId("s1"),
|
||||
task_id: toTaskId("t1"),
|
||||
author_type: "agent",
|
||||
content,
|
||||
type,
|
||||
metadata,
|
||||
created_at: "",
|
||||
turn_id: "turn-1",
|
||||
};
|
||||
}
|
||||
|
||||
function toolActivity(id: string): Message {
|
||||
return message(
|
||||
id,
|
||||
"tool_execute",
|
||||
{
|
||||
status: "complete",
|
||||
normalized: { shell_exec: { command: "gh pr checks", output: { exit_code: 0 } } },
|
||||
},
|
||||
"gh pr checks",
|
||||
);
|
||||
}
|
||||
|
||||
function richOutputCall(): Message {
|
||||
const toolName = "mcp__kandev__show_rich_output_kandev";
|
||||
return message(
|
||||
"rich",
|
||||
"tool_call",
|
||||
{
|
||||
title: toolName,
|
||||
status: "complete",
|
||||
normalized: { kind: "generic", generic: { name: "other" } },
|
||||
},
|
||||
toolName,
|
||||
);
|
||||
}
|
||||
|
||||
describe("buildGroupedRenderItems rich-output hoisting", () => {
|
||||
it("keeps a rich presentation standalone and groups ordinary activity", () => {
|
||||
const items = buildGroupedRenderItems(
|
||||
[
|
||||
toolActivity("t1"),
|
||||
toolActivity("t2"),
|
||||
richOutputCall(),
|
||||
toolActivity("t3"),
|
||||
toolActivity("t4"),
|
||||
],
|
||||
"s1",
|
||||
{ canAnchorPrepareProgress: false },
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual(["turn_group", "message", "turn_group"]);
|
||||
expect(items[1]).toMatchObject({ type: "message", message: { id: "rich" } });
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MessageType,
|
||||
type TaskPendingAction,
|
||||
} from "@/lib/types/http";
|
||||
import { isRichOutputMessage } from "@/components/task/chat/types";
|
||||
import type { ToolCallMetadata } from "@/components/task/chat/types";
|
||||
import {
|
||||
findPendingClarification,
|
||||
@@ -254,7 +255,10 @@ function groupActivityMessages(allMessages: Message[]): RenderItem[] {
|
||||
|
||||
for (const message of allMessages) {
|
||||
const isActivity =
|
||||
message.type && ACTIVITY_MESSAGE_TYPES.has(message.type) && !isSubagentMessage(message);
|
||||
message.type &&
|
||||
ACTIVITY_MESSAGE_TYPES.has(message.type) &&
|
||||
!isSubagentMessage(message) &&
|
||||
!isRichOutputMessage(message);
|
||||
const messageTurnId = message.turn_id ?? null;
|
||||
if (isActivity && messageTurnId) {
|
||||
if (currentGroup.length > 0 && currentTurnId === messageTurnId) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveSettingsDiscovery } from "./resolve";
|
||||
const GITHUB_CONNECTION_ID = "integration-github-connection";
|
||||
const STABLE_CONTROL_IDS = [
|
||||
"appearance-color-theme",
|
||||
"appearance-rich-output-motion",
|
||||
"appearance-startup-page",
|
||||
"appearance-display-language",
|
||||
"terminal-preferred-shell",
|
||||
|
||||
@@ -16,6 +16,7 @@ const TASK_BEHAVIOR_ID = "preferences-task-behavior";
|
||||
export const TASK_BEHAVIOR_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/task-behavior`;
|
||||
export const GENERAL_SETTINGS_TARGETS = {
|
||||
colorTheme: "setting-color-theme",
|
||||
richOutputMotion: "setting-rich-output-motion",
|
||||
settingsMenuMode: "setting-settings-menu-mode",
|
||||
startupPage: "setting-startup-page",
|
||||
displayLanguage: "setting-display-language",
|
||||
@@ -62,6 +63,17 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
targetId: GENERAL_SETTINGS_TARGETS.colorTheme,
|
||||
order: 11,
|
||||
},
|
||||
{
|
||||
id: "appearance-rich-output-motion",
|
||||
kind: "control",
|
||||
labelKey: "settings:animateRichOutputCharts",
|
||||
aliasesKey: "settings:discoveryAliasesRichOutputMotion",
|
||||
parentId: APPEARANCE_ID,
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.richOutputMotion,
|
||||
order: 12,
|
||||
},
|
||||
{
|
||||
id: "appearance-settings-menu",
|
||||
kind: "control",
|
||||
@@ -71,7 +83,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.settingsMenuMode,
|
||||
order: 12,
|
||||
order: 13,
|
||||
},
|
||||
{
|
||||
id: "appearance-startup-page",
|
||||
@@ -82,7 +94,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.startupPage,
|
||||
order: 13,
|
||||
order: 14,
|
||||
},
|
||||
{
|
||||
id: "appearance-display-language",
|
||||
@@ -93,7 +105,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.displayLanguage,
|
||||
order: 14,
|
||||
order: 15,
|
||||
},
|
||||
{
|
||||
id: "appearance-status-bar",
|
||||
@@ -104,7 +116,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.appStatusBar,
|
||||
order: 15,
|
||||
order: 16,
|
||||
},
|
||||
{
|
||||
id: "appearance-changes-panel-layout",
|
||||
@@ -114,7 +126,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.changesPanelLayout,
|
||||
order: 16,
|
||||
order: 17,
|
||||
},
|
||||
{
|
||||
id: "appearance-resource-metrics",
|
||||
@@ -125,7 +137,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
|
||||
groupId: "preferences",
|
||||
href: APPEARANCE_SETTINGS_HREF,
|
||||
targetId: GENERAL_SETTINGS_TARGETS.resourceMetrics,
|
||||
order: 17,
|
||||
order: 18,
|
||||
},
|
||||
{
|
||||
id: "preferences-layouts",
|
||||
|
||||
@@ -7,6 +7,8 @@ export const STORAGE_KEYS = {
|
||||
SETTINGS_MENU_MODE: "kandev.settings.menuMode",
|
||||
/** Branch keys left open in the persistent tree on this device. */
|
||||
SETTINGS_MENU_EXPANDED: "kandev.settings.menuExpanded",
|
||||
/** Whether agent rich-output line and bar charts animate on this device. */
|
||||
RICH_OUTPUT_ANIMATIONS: "kandev.settings.richOutputAnimations",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BACKEND_URL = "http://localhost:38429";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { STORAGE_KEYS } from "./constants";
|
||||
import {
|
||||
DEFAULT_RICH_OUTPUT_ANIMATIONS_ENABLED,
|
||||
readRichOutputAnimationsEnabled,
|
||||
writeRichOutputAnimationsEnabled,
|
||||
} from "./rich-output-motion";
|
||||
|
||||
describe("rich-output motion preference", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
it("defaults to enabled when no preference is stored", () => {
|
||||
expect(DEFAULT_RICH_OUTPUT_ANIMATIONS_ENABLED).toBe(true);
|
||||
expect(readRichOutputAnimationsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to enabled for malformed or non-boolean storage", () => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS, "{not json");
|
||||
expect(readRichOutputAnimationsEnabled()).toBe(true);
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS, JSON.stringify("disabled"));
|
||||
expect(readRichOutputAnimationsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("persists an explicit device preference", () => {
|
||||
writeRichOutputAnimationsEnabled(false);
|
||||
|
||||
expect(readRichOutputAnimationsEnabled()).toBe(false);
|
||||
expect(window.localStorage.getItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS)).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "./constants";
|
||||
|
||||
export const DEFAULT_RICH_OUTPUT_ANIMATIONS_ENABLED = true;
|
||||
|
||||
export function readRichOutputAnimationsEnabled(): boolean {
|
||||
const stored: unknown = getLocalStorage<boolean>(
|
||||
STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS,
|
||||
DEFAULT_RICH_OUTPUT_ANIMATIONS_ENABLED,
|
||||
);
|
||||
return typeof stored === "boolean" ? stored : DEFAULT_RICH_OUTPUT_ANIMATIONS_ENABLED;
|
||||
}
|
||||
|
||||
export function writeRichOutputAnimationsEnabled(enabled: boolean): void {
|
||||
setLocalStorage(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS, enabled);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
import { createUISlice } from "./ui-slice";
|
||||
import type { UISlice } from "./types";
|
||||
|
||||
function makeStore() {
|
||||
return create<UISlice>()(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
immer((...args) => ({ ...(createUISlice as any)(...args) })),
|
||||
);
|
||||
}
|
||||
|
||||
describe("rich-output motion preference actions", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
it("hydrates the current and saved values from device storage", () => {
|
||||
window.localStorage.setItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS, "false");
|
||||
|
||||
const store = makeStore();
|
||||
|
||||
expect(store.getState().richOutputMotion).toEqual({ enabled: false, savedEnabled: false });
|
||||
});
|
||||
|
||||
it("previews, commits, and restores without persisting a preview", () => {
|
||||
const store = makeStore();
|
||||
|
||||
store.getState().previewRichOutputAnimations(false);
|
||||
expect(store.getState().richOutputMotion).toEqual({ enabled: false, savedEnabled: true });
|
||||
expect(window.localStorage.getItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS)).toBeNull();
|
||||
|
||||
store.getState().restoreRichOutputAnimations();
|
||||
expect(store.getState().richOutputMotion.enabled).toBe(true);
|
||||
|
||||
store.getState().commitRichOutputAnimations(false);
|
||||
expect(store.getState().richOutputMotion).toEqual({ enabled: false, savedEnabled: false });
|
||||
expect(window.localStorage.getItem(STORAGE_KEYS.RICH_OUTPUT_ANIMATIONS)).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import {
|
||||
readRichOutputAnimationsEnabled,
|
||||
writeRichOutputAnimationsEnabled,
|
||||
} from "@/lib/settings/rich-output-motion";
|
||||
import type { RichOutputMotionState, UISlice } from "./types";
|
||||
|
||||
export function loadRichOutputMotionState(): RichOutputMotionState {
|
||||
const enabled = readRichOutputAnimationsEnabled();
|
||||
return { enabled, savedEnabled: enabled };
|
||||
}
|
||||
|
||||
type ImmerSet = Parameters<StateCreator<UISlice, [["zustand/immer", never]], [], UISlice>>[0];
|
||||
|
||||
export function buildRichOutputMotionActions(set: ImmerSet) {
|
||||
return {
|
||||
previewRichOutputAnimations: (enabled: boolean) =>
|
||||
set((draft) => {
|
||||
draft.richOutputMotion.enabled = enabled;
|
||||
}),
|
||||
commitRichOutputAnimations: (enabled: boolean) =>
|
||||
set((draft) => {
|
||||
draft.richOutputMotion.enabled = enabled;
|
||||
draft.richOutputMotion.savedEnabled = enabled;
|
||||
writeRichOutputAnimationsEnabled(enabled);
|
||||
}),
|
||||
restoreRichOutputAnimations: () =>
|
||||
set((draft) => {
|
||||
draft.richOutputMotion.enabled = draft.richOutputMotion.savedEnabled;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -227,6 +227,14 @@ export type SettingsMenuState = {
|
||||
expandedKeys: string[];
|
||||
};
|
||||
|
||||
/** Agent rich-output chart motion preference, per device (localStorage). */
|
||||
export type RichOutputMotionState = {
|
||||
/** The value rendered now, including an unsaved Appearance preview. */
|
||||
enabled: boolean;
|
||||
/** The persisted value restored when the Appearance draft is discarded. */
|
||||
savedEnabled: boolean;
|
||||
};
|
||||
|
||||
/** Unified AppSidebar collapse + per-section expand state (localStorage). */
|
||||
export type AppSidebarState = {
|
||||
collapsed: boolean;
|
||||
@@ -285,6 +293,8 @@ export type UISliceState = {
|
||||
appSidebar: AppSidebarState;
|
||||
/** Settings menu shape + open branches (localStorage). */
|
||||
settingsMenu: SettingsMenuState;
|
||||
/** Agent rich-output chart animation preference (localStorage). */
|
||||
richOutputMotion: RichOutputMotionState;
|
||||
/**
|
||||
* Most recently dismissed `last_agent_error` stamp per sessionId. Shared by
|
||||
* the chat banner and the sidebar error icon so dismissing the banner also
|
||||
@@ -422,6 +432,12 @@ export type UISliceActions = {
|
||||
/** Drop an unsaved preview and render the persisted mode again. */
|
||||
restoreSettingsMenuMode: () => void;
|
||||
setSettingsMenuExpandedKeys: (keys: string[]) => void;
|
||||
/** Preview rich-output chart motion without persisting it. */
|
||||
previewRichOutputAnimations: (enabled: boolean) => void;
|
||||
/** Persist rich-output chart motion for this device. */
|
||||
commitRichOutputAnimations: (enabled: boolean) => void;
|
||||
/** Restore the persisted rich-output chart motion preference. */
|
||||
restoreRichOutputAnimations: () => void;
|
||||
/** Record multiple sidebar badge acknowledgements with one localStorage merge. */
|
||||
acknowledgeAgentErrors: (stamps: Record<string, string>) => void;
|
||||
/** Record that `stamp` has been dismissed for `sessionId`. */
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
loadAppSidebarState,
|
||||
} from "./app-sidebar-actions";
|
||||
import { buildSettingsMenuActions, loadSettingsMenuState } from "./settings-menu-actions";
|
||||
import {
|
||||
buildRichOutputMotionActions,
|
||||
loadRichOutputMotionState,
|
||||
} from "./rich-output-motion-actions";
|
||||
import { DEFAULT_SETTINGS_MENU_MODE } from "@/lib/settings/settings-menu-mode";
|
||||
import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
|
||||
import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions";
|
||||
@@ -146,6 +150,10 @@ export const defaultUIState: UISliceState = {
|
||||
savedMode: DEFAULT_SETTINGS_MENU_MODE,
|
||||
expandedKeys: [],
|
||||
},
|
||||
richOutputMotion: {
|
||||
enabled: true,
|
||||
savedEnabled: true,
|
||||
},
|
||||
acknowledgedAgentErrors: {},
|
||||
dismissedAgentErrors: {},
|
||||
};
|
||||
@@ -340,8 +348,10 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
|
||||
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
|
||||
appSidebar: loadAppSidebarState(),
|
||||
settingsMenu: loadSettingsMenuState(),
|
||||
richOutputMotion: loadRichOutputMotionState(),
|
||||
...buildAppSidebarActions(set),
|
||||
...buildSettingsMenuActions(set),
|
||||
...buildRichOutputMotionActions(set),
|
||||
...buildPreviewActions(set),
|
||||
...buildMobileActions(set),
|
||||
...buildBottomTerminalActions(set),
|
||||
|
||||
@@ -248,6 +248,7 @@ export type AppState = KanbanSlice & {
|
||||
sidebarTaskPrefs: (typeof defaultUIState)["sidebarTaskPrefs"];
|
||||
appSidebar: (typeof defaultUIState)["appSidebar"];
|
||||
settingsMenu: (typeof defaultUIState)["settingsMenu"];
|
||||
richOutputMotion: (typeof defaultUIState)["richOutputMotion"];
|
||||
acknowledgedAgentErrors: (typeof defaultUIState)["acknowledgedAgentErrors"];
|
||||
dismissedAgentErrors: (typeof defaultUIState)["dismissedAgentErrors"];
|
||||
|
||||
@@ -568,6 +569,9 @@ export type AppState = KanbanSlice & {
|
||||
commitSettingsMenuMode: UIA["commitSettingsMenuMode"];
|
||||
restoreSettingsMenuMode: UIA["restoreSettingsMenuMode"];
|
||||
setSettingsMenuExpandedKeys: UIA["setSettingsMenuExpandedKeys"];
|
||||
previewRichOutputAnimations: UIA["previewRichOutputAnimations"];
|
||||
commitRichOutputAnimations: UIA["commitRichOutputAnimations"];
|
||||
restoreRichOutputAnimations: UIA["restoreRichOutputAnimations"];
|
||||
acknowledgeAgentErrors: UIA["acknowledgeAgentErrors"];
|
||||
dismissAgentError: UIA["dismissAgentError"];
|
||||
} & GitHubSliceActions &
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"agentGeneratedTaskTitles": "Agent-generated task titles",
|
||||
"agentGeneratedTaskTitlesDescription": "When enabled, new task and subtask forms hide the manual title field and require a prompt. Kandev shows a provisional title using the prompt's first six words immediately, then asks the agent to replace it with a concise title targeting about three words. If the agent cannot rename the task, the provisional title remains available for you to edit. Existing and edited tasks keep their normal title field.",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "Disabled by default; save this setting before opening a creation dialog.",
|
||||
"animateRichOutputCharts": "Animate rich-output charts",
|
||||
"animateRichOutputChartsDescription": "Animate line and bar charts when agents present them. Turn this off to reduce browser work; chart data and controls stay available. Your operating system's reduced-motion setting always takes priority. Saved on this device.",
|
||||
"apiTokenIsConfigured": "API token is configured.",
|
||||
"appearance": "Appearance",
|
||||
"appriseNotInstalled": "Apprise is not installed yet. You can add it later to enable remote notifications. <2>View installation instructions</2>.",
|
||||
@@ -192,6 +194,7 @@
|
||||
"discoveryAliasesNotificationSound": "notification audio,chime,alert sound",
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,terminal shell",
|
||||
"discoveryAliasesResourceMetrics": "metrics,CPU,memory,disk,status bar",
|
||||
"discoveryAliasesRichOutputMotion": "chart animation,graphs,performance,reduced motion",
|
||||
"discoveryAliasesSettingsMenu": "sidebar,navigation,tree,accordion,menu",
|
||||
"discoveryAliasesStartupPage": "home page,start page,launch page",
|
||||
"discoveryAliasesStatusBar": "bottom bar,status drawer,connection status,appearance",
|
||||
@@ -517,6 +520,7 @@
|
||||
"resetToDefault": "Reset to default",
|
||||
"resizeRepositionOrMergeTheSelected": "Resize, reposition, or merge the selected split.",
|
||||
"resourceMetrics": "Resource Metrics",
|
||||
"richOutputChartMotion": "Rich-output Chart Motion",
|
||||
"restoreTheOriginalBuiltInLayout": "Restore the original built-in layout and discard its override.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Resume the most recently opened task in this workspace on this device.",
|
||||
"retrySave": "Retry save",
|
||||
|
||||
@@ -1188,6 +1188,15 @@
|
||||
"retrying": "Retrying...",
|
||||
"reverseISearch": "(reverse-i-search)",
|
||||
"revertCommit": "Revert commit",
|
||||
"richOutputBinaryPreviewUnavailable": "Preview unavailable for this file type.",
|
||||
"richOutputHidePreview": "Hide preview",
|
||||
"richOutputLoadingPreview": "Loading preview",
|
||||
"richOutputOpenFile": "Open file",
|
||||
"richOutputPreview": "Preview",
|
||||
"richOutputPreviewFailed": "Preview unavailable.",
|
||||
"richOutputPreviewTruncated": "Preview clipped.",
|
||||
"richOutputTool": "Kandev: visual output",
|
||||
"richOutputUnavailable": "This presentation is unavailable.",
|
||||
"review": "Review",
|
||||
"reviewComments": "Review comments",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} review comment ready to send",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"agentGeneratedTaskTitles": "Àĝēńţ-ĝēńēŕàţēď ţàśķ ţĩţĺēś",
|
||||
"agentGeneratedTaskTitlesDescription": "Ŵĥēń ēńàƀĺēď, ńēŵ ţàśķ àńď śũƀţàśķ ƒōŕḿś ĥĩďē ţĥē ḿàńũàĺ ţĩţĺē ƒĩēĺď àńď ŕēqũĩŕē à ƥŕōḿƥţ. Ķàńďēv śĥōŵś à ƥŕōvĩśĩōńàĺ ţĩţĺē ũśĩńĝ ţĥē ƥŕōḿƥţ'ś ƒĩŕśţ śĩx ŵōŕďś ĩḿḿēďĩàţēĺŷ, ţĥēń àśķś ţĥē àĝēńţ ţō ŕēƥĺàćē ĩţ ŵĩţĥ à ćōńćĩśē ţĩţĺē ţàŕĝēţĩńĝ àƀōũţ ţĥŕēē ŵōŕďś. Ĩƒ ţĥē àĝēńţ ćàńńōţ ŕēńàḿē ţĥē ţàśķ, ţĥē ƥŕōvĩśĩōńàĺ ţĩţĺē ŕēḿàĩńś àvàĩĺàƀĺē ƒōŕ ŷōũ ţō ēďĩţ. Ēxĩśţĩńĝ àńď ēďĩţēď ţàśķś ķēēƥ ţĥēĩŕ ńōŕḿàĺ ţĩţĺē ƒĩēĺď.",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "Ďĩśàƀĺēď ƀŷ ďēƒàũĺţ; śàvē ţĥĩś śēţţĩńĝ ƀēƒōŕē ōƥēńĩńĝ à ćŕēàţĩōń ďĩàĺōĝ.",
|
||||
"animateRichOutputCharts": "Àńĩḿàţē ŕĩćĥ-ōũţƥũţ ćĥàŕţś",
|
||||
"animateRichOutputChartsDescription": "Àńĩḿàţē ĺĩńē àńď ƀàŕ ćĥàŕţś ŵĥēń àĝēńţś ƥŕēśēńţ ţĥēḿ. Ţũŕń ţĥĩś ōƒƒ ţō ŕēďũćē ƀŕōŵśēŕ ŵōŕķ; ćĥàŕţ ďàţà àńď ćōńţŕōĺś śţàŷ àvàĩĺàƀĺē. Ŷōũŕ ōƥēŕàţĩńĝ śŷśţēḿ'ś ŕēďũćēď-ḿōţĩōń śēţţĩńĝ àĺŵàŷś ţàķēś ƥŕĩōŕĩţŷ. Śàvēď ōń ţĥĩś ďēvĩćē.",
|
||||
"apiTokenIsConfigured": "ÀƤĨ ţōķēń ĩś ćōńƒĩĝũŕēď.",
|
||||
"appearance": "Àƥƥēàŕàńćē",
|
||||
"appriseNotInstalled": "Àƥƥŕĩśē ĩś ńōţ ĩńśţàĺĺēď ŷēţ. Ŷōũ ćàń àďď ĩţ ĺàţēŕ ţō ēńàƀĺē ŕēḿōţē ńōţĩƒĩćàţĩōńś. <2>Vĩēŵ ĩńśţàĺĺàţĩōń ĩńśţŕũćţĩōńś</2>.",
|
||||
@@ -192,6 +194,7 @@
|
||||
"discoveryAliasesNotificationSound": "ńōţĩƒĩćàţĩōń àũďĩō,ćĥĩḿē,àĺēŕţ śōũńď",
|
||||
"discoveryAliasesPreferredShell": "śĥēĺĺ,źśĥ,ƀàśĥ,ţēŕḿĩńàĺ śĥēĺĺ",
|
||||
"discoveryAliasesResourceMetrics": "ḿēţŕĩćś,ĆƤŨ,ḿēḿōŕŷ,ďĩśķ,śţàţũś ƀàŕ",
|
||||
"discoveryAliasesRichOutputMotion": "ćĥàŕţ àńĩḿàţĩōń,ĝŕàƥĥś,ƥēŕƒōŕḿàńćē,ŕēďũćēď ḿōţĩōń",
|
||||
"discoveryAliasesSettingsMenu": "śĩďēƀàŕ,ńàvĩĝàţĩōń,ţŕēē,àććōŕďĩōń,ḿēńũ",
|
||||
"discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē",
|
||||
"discoveryAliasesStatusBar": "ƀōţţōḿ ƀàŕ,śţàţũś ďŕàŵēŕ,ćōńńēćţĩōń śţàţũś,àƥƥēàŕàńćē",
|
||||
@@ -517,6 +520,7 @@
|
||||
"resetToDefault": "Ŕēśēţ ţō ďēƒàũĺţ",
|
||||
"resizeRepositionOrMergeTheSelected": "Ŕēśĩźē, ŕēƥōśĩţĩōń, ōŕ ḿēŕĝē ţĥē śēĺēćţēď śƥĺĩţ.",
|
||||
"resourceMetrics": "Ŕēśōũŕćē Ḿēţŕĩćś",
|
||||
"richOutputChartMotion": "Ŕĩćĥ-ōũţƥũţ Ćĥàŕţ Ḿōţĩōń",
|
||||
"restoreTheOriginalBuiltInLayout": "Ŕēśţōŕē ţĥē ōŕĩĝĩńàĺ ƀũĩĺţ-ĩń ĺàŷōũţ àńď ďĩśćàŕď ĩţś ōvēŕŕĩďē.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Ŕēśũḿē ţĥē ḿōśţ ŕēćēńţĺŷ ōƥēńēď ţàśķ ĩń ţĥĩś ŵōŕķśƥàćē ōń ţĥĩś ďēvĩćē.",
|
||||
"retrySave": "Ŕēţŕŷ śàvē",
|
||||
|
||||
@@ -1188,6 +1188,15 @@
|
||||
"retrying": "Ŕēţŕŷĩńĝ...",
|
||||
"reverseISearch": "(ŕēvēŕśē-ĩ-śēàŕćĥ)",
|
||||
"revertCommit": "Ŕēvēŕţ ćōḿḿĩţ",
|
||||
"richOutputBinaryPreviewUnavailable": "Ƥŕēvĩēŵ ũńàvàĩĺàƀĺē ƒōŕ ţĥĩś ƒĩĺē ţŷƥē.",
|
||||
"richOutputHidePreview": "Ĥĩďē ƥŕēvĩēŵ",
|
||||
"richOutputLoadingPreview": "Ĺōàďĩńĝ ƥŕēvĩēŵ",
|
||||
"richOutputOpenFile": "Ōƥēń ƒĩĺē",
|
||||
"richOutputPreview": "Ƥŕēvĩēŵ",
|
||||
"richOutputPreviewFailed": "Ƥŕēvĩēŵ ũńàvàĩĺàƀĺē.",
|
||||
"richOutputPreviewTruncated": "Ƥŕēvĩēŵ ćĺĩƥƥēď.",
|
||||
"richOutputTool": "Ķàńďēv: vĩśũàĺ ōũţƥũţ",
|
||||
"richOutputUnavailable": "Ţĥĩś ƥŕēśēńţàţĩōń ĩś ũńàvàĩĺàƀĺē.",
|
||||
"review": "Ŕēvĩēŵ",
|
||||
"reviewComments": "Ŕēvĩēŵ ćōḿḿēńţś",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} ŕēvĩēŵ ćōḿḿēńţ ŕēàďŷ ţō śēńď",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"agentGeneratedTaskTitles": "Títulos de tarefa gerados pelo agente",
|
||||
"agentGeneratedTaskTitlesDescription": "Quando está ativado, os formulários de nova tarefa e de subtarefa ocultam o campo manual de título e exigem um prompt. O Kandev mostra de imediato um título provisório com as primeiras seis palavras do prompt e depois pede ao agente que o substitua por um título conciso, com cerca de três palavras. Se o agente não conseguir mudar o nome da tarefa, o título provisório continua disponível para edição. As tarefas existentes e editadas mantêm o seu campo de título normal.",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "Desativado por pré-definição; guarde esta definição antes de abrir uma janela de criação.",
|
||||
"animateRichOutputCharts": "Animar gráficos apresentados pelos agentes",
|
||||
"animateRichOutputChartsDescription": "Anime os gráficos de linhas e de barras quando os agentes os apresentam. Desative esta opção para reduzir o trabalho do navegador; os dados e os controlos dos gráficos continuam disponíveis. A definição de redução de movimento do sistema operativo tem sempre prioridade. Guardado neste dispositivo.",
|
||||
"apiTokenIsConfigured": "O token de API está configurado.",
|
||||
"appearance": "Aspeto",
|
||||
"appriseNotInstalled": "O Apprise ainda não está instalado. Pode adicioná-lo mais tarde para ativar as notificações remotas. <2>Ver as instruções de instalação</2>.",
|
||||
@@ -189,6 +191,7 @@
|
||||
"discoveryAliasesNotificationSound": "áudio de notificação,sino,som de alerta",
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,shell do terminal",
|
||||
"discoveryAliasesResourceMetrics": "métricas,CPU,memória,disco,barra de estado",
|
||||
"discoveryAliasesRichOutputMotion": "animação de gráficos,grafos,desempenho,movimento reduzido",
|
||||
"discoveryAliasesStartupPage": "página inicial,página de arranque,página de lançamento",
|
||||
"discoveryAliasesSubmitShortcut": "tecla de envio,enter para enviar,command enter",
|
||||
"discoveryAliasesTerminalFont": "família de letra,tipo de letra,monoespaçado",
|
||||
@@ -503,6 +506,7 @@
|
||||
"resetToDefault": "Repor a pré-definição",
|
||||
"resizeRepositionOrMergeTheSelected": "Redimensionar, reposicionar ou fundir a divisão selecionada.",
|
||||
"resourceMetrics": "Métricas de recursos",
|
||||
"richOutputChartMotion": "Movimento dos gráficos apresentados pelo agente",
|
||||
"restoreTheOriginalBuiltInLayout": "Restaurar o esquema incorporado original e descartar a sua substituição.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Retomar a tarefa aberta mais recentemente nesta área de trabalho, neste dispositivo.",
|
||||
"retrySave": "Repetir a gravação",
|
||||
|
||||
@@ -1143,6 +1143,15 @@
|
||||
"retrying": "A tentar novamente...",
|
||||
"reverseISearch": "(reverse-i-search)",
|
||||
"revertCommit": "Reverter o commit",
|
||||
"richOutputBinaryPreviewUnavailable": "A pré-visualização não está disponível para este tipo de ficheiro.",
|
||||
"richOutputHidePreview": "Ocultar pré-visualização",
|
||||
"richOutputLoadingPreview": "A carregar pré-visualização",
|
||||
"richOutputOpenFile": "Abrir ficheiro",
|
||||
"richOutputPreview": "Pré-visualizar",
|
||||
"richOutputPreviewFailed": "Pré-visualização indisponível.",
|
||||
"richOutputPreviewTruncated": "A pré-visualização foi cortada.",
|
||||
"richOutputTool": "Kandev: resultado visual",
|
||||
"richOutputUnavailable": "Esta apresentação não está disponível.",
|
||||
"review": "Revisão",
|
||||
"reviewComments": "Comentários da revisão",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} comentário de revisão pronto a enviar",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"agentGeneratedTaskTitles": "由智能体生成任务标题",
|
||||
"agentGeneratedTaskTitlesDescription": "启用后,新建任务和子任务表单会隐藏手动标题字段,并要求填写提示词。Kandev 会立即使用提示词的前六个词生成临时标题,然后让智能体将其替换为约三个词的简洁标题。如果智能体无法重命名任务,你仍可编辑临时标题。现有任务和经过编辑的任务会继续显示常规标题字段。",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "默认关闭;请先保存此设置,再打开创建对话框。",
|
||||
"animateRichOutputCharts": "为智能体展示的图表启用动画",
|
||||
"animateRichOutputChartsDescription": "智能体展示折线图和条形图时播放动画。关闭此选项可减少浏览器负载;图表数据和控件仍然可用。操作系统的“减少动态效果”设置始终优先。此设置保存在当前设备上。",
|
||||
"apiTokenIsConfigured": "API 令牌已配置。",
|
||||
"appearance": "外观",
|
||||
"appriseNotInstalled": "Apprise 尚未安装。你可以稍后添加它,以启用远程通知。<2>查看安装说明</2>。",
|
||||
@@ -192,6 +194,7 @@
|
||||
"discoveryAliasesNotificationSound": "通知音,提示音,警报音",
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,终端 shell",
|
||||
"discoveryAliasesResourceMetrics": "指标,CPU,内存,磁盘,状态栏",
|
||||
"discoveryAliasesRichOutputMotion": "图表动画,图形,性能,减少动态效果",
|
||||
"discoveryAliasesSettingsMenu": "侧边栏,导航,树形菜单,单分支展开,保留展开,菜单",
|
||||
"discoveryAliasesStartupPage": "主页,起始页,启动页",
|
||||
"discoveryAliasesStatusBar": "底栏,状态面板,连接状态,外观",
|
||||
@@ -516,6 +519,7 @@
|
||||
"resetToDefault": "恢复默认设置",
|
||||
"resizeRepositionOrMergeTheSelected": "调整所选拆分区域的大小、位置或合并它。",
|
||||
"resourceMetrics": "资源指标",
|
||||
"richOutputChartMotion": "智能体图表动态效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢复原始内置布局并舍弃其覆盖设置。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此设备上恢复此工作区中最近打开的任务。",
|
||||
"retrySave": "重试保存",
|
||||
|
||||
@@ -1142,6 +1142,15 @@
|
||||
"retrying": "正在重试…",
|
||||
"reverseISearch": "(reverse-i-search)",
|
||||
"revertCommit": "还原提交",
|
||||
"richOutputBinaryPreviewUnavailable": "无法预览此文件类型。",
|
||||
"richOutputHidePreview": "隐藏预览",
|
||||
"richOutputLoadingPreview": "正在加载预览",
|
||||
"richOutputOpenFile": "打开文件",
|
||||
"richOutputPreview": "预览",
|
||||
"richOutputPreviewFailed": "预览不可用。",
|
||||
"richOutputPreviewTruncated": "预览已截断。",
|
||||
"richOutputTool": "Kandev:可视化输出",
|
||||
"richOutputUnavailable": "此演示内容不可用。",
|
||||
"review": "审阅",
|
||||
"reviewComments": "审阅评论",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} 条审阅评论待发送",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"agentGeneratedTaskTitles": "由代理程式產生任務標題",
|
||||
"agentGeneratedTaskTitlesDescription": "啓用後,新建任務和子任務表單會隱藏手動標題欄位,並要求填寫提示詞。Kandev 會立即使用提示詞的前六個詞產生臨時標題,然後讓代理程式將其替換為約三個詞的簡潔標題。如果代理程式無法重命名任務,你仍可編輯臨時標題。現有任務和經過編輯的任務會繼續顯示常規標題欄位。",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "預設關閉;請先儲存此設定,再開啟建立對話框。",
|
||||
"animateRichOutputCharts": "為代理程式展示的圖表啓用動畫",
|
||||
"animateRichOutputChartsDescription": "代理程式展示折線圖和條形圖時播放動畫。關閉此選項可減少瀏覽器負載;圖表數據和控制項仍然可用。操作系統的「減少動態效果」設定始終優先。此設定儲存在目前設備上。",
|
||||
"apiTokenIsConfigured": "API 權杖已設定。",
|
||||
"appearance": "外觀",
|
||||
"appriseNotInstalled": "Apprise 尚未安裝。你可以稍後新增它,以啓用遠端通知。<2>查看安裝說明</2>。",
|
||||
@@ -192,6 +194,7 @@
|
||||
"discoveryAliasesNotificationSound": "通知音,提示音,警報音",
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,終端 shell",
|
||||
"discoveryAliasesResourceMetrics": "指標,CPU,記憶體,磁碟,狀態欄",
|
||||
"discoveryAliasesRichOutputMotion": "圖表動畫,圖形,性能,減少動態效果",
|
||||
"discoveryAliasesSettingsMenu": "側邊欄,導航,樹形菜單,單分支展開,保留展開,菜單",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啓動頁",
|
||||
"discoveryAliasesStatusBar": "底欄,狀態面板,連接狀態,外觀",
|
||||
@@ -516,6 +519,7 @@
|
||||
"resetToDefault": "恢復預設設定",
|
||||
"resizeRepositionOrMergeTheSelected": "調整所選拆分區域的大小、位置或合併它。",
|
||||
"resourceMetrics": "資源指標",
|
||||
"richOutputChartMotion": "代理程式圖表動態效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢復原始內置佈局並捨棄其覆蓋設定。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此設備上恢復此工作區中最近開啟的任務。",
|
||||
"retrySave": "重試儲存",
|
||||
|
||||
@@ -1142,6 +1142,15 @@
|
||||
"retrying": "正在重試…",
|
||||
"reverseISearch": "(reverse-i-search)",
|
||||
"revertCommit": "還原提交",
|
||||
"richOutputBinaryPreviewUnavailable": "無法預覽此檔案類型。",
|
||||
"richOutputHidePreview": "隱藏預覽",
|
||||
"richOutputLoadingPreview": "正在載入預覽",
|
||||
"richOutputOpenFile": "開啟檔案",
|
||||
"richOutputPreview": "預覽",
|
||||
"richOutputPreviewFailed": "預覽不可用。",
|
||||
"richOutputPreviewTruncated": "預覽已截斷。",
|
||||
"richOutputTool": "Kandev:可視化輸出",
|
||||
"richOutputUnavailable": "此演示內容不可用。",
|
||||
"review": "審閱",
|
||||
"reviewComments": "審閱評論",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} 條審閱評論待傳送",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"agentGeneratedTaskTitles": "由代理程式產生任務標題",
|
||||
"agentGeneratedTaskTitlesDescription": "啟用後,新建任務和子任務表單會隱藏手動標題欄位,並要求填寫提示詞。Kandev 會立即使用提示詞的前六個詞產生臨時標題,然後讓代理程式將其替換為約三個詞的簡潔標題。如果代理程式無法重新命名任務,你仍可編輯臨時標題。現有任務和經過編輯的任務會繼續顯示常規標題欄位。",
|
||||
"agentGeneratedTaskTitlesDisabledHint": "預設關閉;請先儲存此設定,再開啟建立對話方塊。",
|
||||
"animateRichOutputCharts": "為代理程式展示的圖表啟用動畫",
|
||||
"animateRichOutputChartsDescription": "代理程式展示折線圖和條形圖時播放動畫。關閉此選項可減少瀏覽器負載;圖表資料和控制項仍然可用。作業系統的「減少動態效果」設定始終優先。此設定儲存在目前裝置上。",
|
||||
"apiTokenIsConfigured": "API 權杖已設定。",
|
||||
"appearance": "外觀",
|
||||
"appriseNotInstalled": "Apprise 尚未安裝。你可以稍後新增它,以啟用遠端通知。<2>檢視安裝說明</2>。",
|
||||
@@ -192,6 +194,7 @@
|
||||
"discoveryAliasesNotificationSound": "通知音,提示音,警報音",
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,終端 shell",
|
||||
"discoveryAliasesResourceMetrics": "指標,CPU,記憶體,磁碟,狀態列",
|
||||
"discoveryAliasesRichOutputMotion": "圖表動畫,圖形,效能,減少動態效果",
|
||||
"discoveryAliasesSettingsMenu": "側邊欄,導覽,樹形選單,單分支展開,保留展開,選單",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啟動頁",
|
||||
"discoveryAliasesStatusBar": "底欄,狀態面板,連線狀態,外觀",
|
||||
@@ -516,6 +519,7 @@
|
||||
"resetToDefault": "恢復預設設定",
|
||||
"resizeRepositionOrMergeTheSelected": "調整所選拆分割槽域的大小、位置或合併它。",
|
||||
"resourceMetrics": "資源指標",
|
||||
"richOutputChartMotion": "代理程式圖表動態效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢復原始內建佈局並捨棄其覆蓋設定。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此裝置上恢復此工作區中最近開啟的任務。",
|
||||
"retrySave": "重試儲存",
|
||||
|
||||
@@ -1142,6 +1142,15 @@
|
||||
"retrying": "正在重試…",
|
||||
"reverseISearch": "(reverse-i-search)",
|
||||
"revertCommit": "還原提交",
|
||||
"richOutputBinaryPreviewUnavailable": "無法預覽此檔案型別。",
|
||||
"richOutputHidePreview": "隱藏預覽",
|
||||
"richOutputLoadingPreview": "正在載入預覽",
|
||||
"richOutputOpenFile": "開啟檔案",
|
||||
"richOutputPreview": "預覽",
|
||||
"richOutputPreviewFailed": "預覽不可用。",
|
||||
"richOutputPreviewTruncated": "預覽已截斷。",
|
||||
"richOutputTool": "Kandev:視覺化輸出",
|
||||
"richOutputUnavailable": "此演示內容不可用。",
|
||||
"review": "審閱",
|
||||
"reviewComments": "審閱評論",
|
||||
"reviewCommentsReadyToSend_one": "{{count}} 條審閱評論待傳送",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-2026-08-14-kandev-native-agent-rich-output: Keep Agent Rich Output Host Native
|
||||
|
||||
**Status:** accepted (amended 2026-08-15)
|
||||
**Date:** 2026-08-14
|
||||
**Area:** backend, frontend, protocol, security
|
||||
|
||||
## Context
|
||||
|
||||
Agents need to present charts, metrics, and workspace files more directly than
|
||||
plain prose allows. Kandev could render its own closed semantic blocks, repair
|
||||
the generic MCP/ACP content pipeline, or implement MCP Apps and host arbitrary
|
||||
sandboxed HTML. Those choices have materially different portability, visual
|
||||
consistency, persistence, and security costs.
|
||||
|
||||
## Decision
|
||||
|
||||
1. The first rich-output surface is a built-in task and Office MCP tool,
|
||||
`show_rich_output_kandev`, with a versioned, closed semantic schema.
|
||||
2. Kandev owns rendering. Agent input contains data and workspace-relative
|
||||
references only; it cannot provide HTML, JavaScript, CSS, remote resources,
|
||||
colors, animations, or arbitrary layout.
|
||||
3. Version 1 supports file, chart, and metrics blocks. Ordinary Markdown owns
|
||||
small tables and prose.
|
||||
4. Existing tool-call arguments at `metadata.normalized.generic.input` are the
|
||||
canonical persisted presentation for inline blocks. A CSV-backed chart is
|
||||
the narrow exception: the handler reads the workspace file once and returns
|
||||
a bounded, versioned snapshot of normalized labels and numeric or `null`
|
||||
values. Existing tool-result persistence stores that snapshot at
|
||||
`metadata.normalized.generic.output`; replay never re-reads the CSV.
|
||||
5. Heavy bytes remain workspace-owned. File blocks and CSV chart sources accept
|
||||
only relative paths and optional repository identity. File previews still
|
||||
fetch content only after explicit user action. CSV parsing persists no raw
|
||||
bytes, but its bounded normalized snapshot remains after workspace cleanup.
|
||||
6. The frontend repeats validation at its trust boundary and renders completed
|
||||
rich-output calls outside generic tool-activity grouping.
|
||||
7. Portable MCP content and MCP Apps remain separate future capabilities. They
|
||||
must not weaken or become implicit escape hatches in this native contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Kandev can guarantee its visual language, responsive behavior, localization,
|
||||
and accessibility for the supported blocks.
|
||||
- Inline payloads and normalized CSV snapshots remain small and replayable
|
||||
without a schema migration.
|
||||
- File content retains task-workspace lifecycle rather than gaining an implied
|
||||
artifact-retention guarantee.
|
||||
- Other MCP hosts cannot reproduce the Kandev-native presentation from this
|
||||
tool unless they adopt the same contract; they still receive a text result.
|
||||
- New block types require an explicit schema version-compatible addition,
|
||||
native desktop/mobile composition, and validation coverage.
|
||||
- Generic MCP images, audio, resources, and MCP Apps still require independent
|
||||
transport, persistence, capability, and security work.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Repair portable MCP content first
|
||||
|
||||
Rejected for the first slice because standard content blocks carry media and
|
||||
resources but do not define a tasteful chart or metric composition. Kandev
|
||||
also currently drops non-text content before durable transcript persistence,
|
||||
which would enlarge the initial change substantially.
|
||||
|
||||
### Implement MCP Apps first
|
||||
|
||||
Rejected because `ui://` resources and sandboxed applications add resource
|
||||
capabilities, iframe lifecycle, CSP, permissions, origins, message bridging,
|
||||
accessibility, and theming policy. That flexibility is justified for true
|
||||
mini-apps, not for the three common display primitives in version 1.
|
||||
|
||||
### Allow arbitrary HTML or a generic component schema
|
||||
|
||||
Rejected because sanitization does not create visual consistency, mobile
|
||||
quality, accessibility, or bounded interaction. A generic component escape
|
||||
hatch would turn agent output into an unreviewed UI framework.
|
||||
|
||||
### Store file bytes in message metadata
|
||||
|
||||
Rejected because large base64 values would bloat SQLite and message replay,
|
||||
duplicate workspace ownership, and create an unclear cleanup contract.
|
||||
|
||||
### Resolve CSV again on every replay
|
||||
|
||||
Rejected because task cleanup, edits, or branch changes could make conversation
|
||||
history disappear or silently change. Resolution therefore happens during the
|
||||
tool call, and replay consumes the persisted normalized snapshot.
|
||||
|
||||
### Require agents to inline CSV rows
|
||||
|
||||
Rejected because it spends model context on mechanical extraction, increases
|
||||
schema-call failures, and discourages agents from using charts for existing
|
||||
workspace data. A closed path-and-column descriptor keeps agent input compact
|
||||
while preserving the same bounded native renderer.
|
||||
@@ -179,3 +179,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 |
|
||||
| 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 |
|
||||
| 2026-08-18-never-started-agent-stall-terminal | [Treat Never-Started Agent Stalls as Terminal](2026-08-18-never-started-agent-stall-terminal.md) | accepted | backend, frontend, protocol | 2026-08-18 |
|
||||
| 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 |
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
spec: docs/specs/agent-rich-output/spec.md
|
||||
created: 2026-08-14
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Agent Rich Output
|
||||
|
||||
## Overview
|
||||
|
||||
Record the host-native trust and persistence boundary first. Add a strict
|
||||
task/Office MCP tool next, then render its persisted inline arguments or
|
||||
normalized CSV result as a standalone chat presentation. Finish with native
|
||||
file, chart, and metric blocks, mobile parity, focused E2E proof, and public
|
||||
MCP reference docs.
|
||||
|
||||
## Backend
|
||||
|
||||
### MCP schema and profile
|
||||
|
||||
- `apps/backend/internal/mcp/server/rich_output.go`: define the closed tool
|
||||
schema, decoded contracts, semantic limits, path validation,
|
||||
`validateRichOutput`, `showRichOutputHandler`, and
|
||||
`registerRichOutputTool`.
|
||||
- `apps/backend/internal/mcp/server/server.go`: add one profile group enabled
|
||||
only for `SurfaceKanbanTask` and `SurfaceOfficeTask`.
|
||||
- `apps/backend/config/prompts/kandev-context.md` and `office-context.md`: list
|
||||
the exact tool and plain-text-first authoring rubric.
|
||||
|
||||
No database, service, or repository change is required. Inline presentations
|
||||
replay from normalized tool-call input. CSV charts reuse the existing
|
||||
`workspace.file.get` action once and replay from their normalized tool result.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Contract and transcript dispatch
|
||||
|
||||
- Add a closed discriminated union and fail-closed parser under
|
||||
`apps/web/components/task/chat/messages/kandev/rich-output/`.
|
||||
- Add `isRichOutputMessage` to
|
||||
`apps/web/components/task/chat/types.ts` and exempt it in
|
||||
`apps/web/hooks/use-processed-messages.ts` so completed presentations remain
|
||||
standalone.
|
||||
- Register `show_rich_output` in the existing Kandev renderer registry and
|
||||
pass session/file-opening context through `KandevToolMessage`.
|
||||
|
||||
### Native blocks
|
||||
|
||||
- Compose one quiet outer presentation with existing `KandevRow` and
|
||||
`KandevBody` patterns.
|
||||
- Render metrics with tabular numbers and responsive wrapping.
|
||||
- Render line/bar charts through `@kandev/ui` chart primitives and host theme
|
||||
tokens. Render direct Recharts axis and tooltip children, use host-owned tick
|
||||
formatting, and provide a visible series legend with local multi-series
|
||||
filtering. Use no new dependency or agent-selected colors.
|
||||
- Render file metadata without fetching. On explicit expansion, call
|
||||
`requestFileContent`; clip text previews, outline images neutrally, and route
|
||||
open through the existing desktop/mobile viewer.
|
||||
- Widen the central file-open callback to accept optional repository identity
|
||||
while preserving one-argument callers.
|
||||
- Add translated host copy in the task namespace and regenerate pseudo locale.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- Outcome and entry point: inspect the same chronological presentation in the
|
||||
existing mobile transcript.
|
||||
- Exemplar: inline chat message composition plus `MobileFileViewerPanel` for
|
||||
focused file content.
|
||||
- Hierarchy: title, description, ordered blocks; the transcript owns vertical
|
||||
scrolling.
|
||||
- Surface rationale: results remain inline conversation evidence; file content
|
||||
moves to the existing full-height viewer because dense content should not be
|
||||
squeezed into chat.
|
||||
- Geometry: 44px actions, bounded chart width/aspect, wrapping metric grid,
|
||||
zero document horizontal overflow, and no hover-only action.
|
||||
|
||||
## Tests
|
||||
|
||||
- **MCP contract and limits:**
|
||||
`apps/backend/internal/mcp/server/rich_output_test.go`; table-driven valid and
|
||||
invalid payloads plus profile presence.
|
||||
- **Profile inventories and prompt parity:** extend
|
||||
`server_test.go` and keep `sysprompt_sync_test.go` exact.
|
||||
- **Frontend parser and message predicate:** add pure TypeScript tests beside
|
||||
the parser and in `components/task/chat/types.test.ts`.
|
||||
- **Transcript grouping:** extend `apps/web/hooks/use-processed-messages.test.ts`
|
||||
to preserve ordinary activity grouping and exempt rich output.
|
||||
- Pure UI markup uses focused React DOM coverage in
|
||||
`rich-output-renderer.test.tsx` and `chart-block.test.tsx`; Playwright is
|
||||
reserved for persistence, browser chart geometry, and file-viewer behavior.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Desktop:** `apps/web/e2e/tests/chat/rich-output.spec.ts` calls the real
|
||||
inline mock-agent MCP directive, observes metrics, two charts, and a file,
|
||||
reloads, expands and opens the seeded file, and proves the result is not
|
||||
activity-grouped.
|
||||
- **Mobile:** `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts` proves chart
|
||||
containment, metric reflow, touch targets, no horizontal overflow, and file
|
||||
opening in the existing mobile viewer.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `cd apps/backend && go test ./internal/mcp/server/...`: passed.
|
||||
- Focused Vitest parser, predicate, grouping, and renderer suite: 4 files and
|
||||
95 tests passed.
|
||||
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and
|
||||
`pnpm run i18n:ratchet`: passed. The i18n check retained 961 existing
|
||||
advisory real-locale parity notices; pseudo is synchronized.
|
||||
- Desktop Chromium rich-output E2E: 1 test passed, including reload, lazy
|
||||
preview, and file opening.
|
||||
- Mobile Chrome rich-output E2E: 1 test passed, including containment, 44px
|
||||
actions, preview, and the native file viewer.
|
||||
- Focused E2E sleep lint, 61 public-doc validator tests, validation of all 41
|
||||
published pages, and `git diff --check`: passed.
|
||||
- CSV extension: `go test ./internal/mcp/server/... -count=1` and focused
|
||||
`golangci-lint` passed with zero issues; focused rich-output Vitest passed 3
|
||||
files and 73 tests.
|
||||
- CSV extension: typecheck, full web lint, i18n check/ratchet, desktop Chromium,
|
||||
and mobile Chrome passed. Desktop replay remained rendered after deleting the
|
||||
source CSV; mobile rendered both charts without horizontal overflow.
|
||||
- Axis/legend repair: 62 focused frontend tests, backend MCP tests, typecheck,
|
||||
full web lint, i18n checks, desktop Chromium, and mobile Chrome passed. Both
|
||||
browsers proved visible x/y ticks, raw-value tooltip content, single-series
|
||||
labels, multi-series filtering, replay, touch targets, and containment.
|
||||
- Public reference validation passed all 61 validator tests and 41 published
|
||||
pages after documenting automatic axes, tooltips, legend filtering, and
|
||||
unit-bearing series labels.
|
||||
- Real-agent ACP delivery: the broad agentctl/MCP Go suite passed; focused
|
||||
rich-output/settings Vitest passed 10 files and 86 tests; typecheck and i18n
|
||||
checks passed; desktop Chromium passed 2 tests and mobile Chrome passed 1.
|
||||
A fresh full-access, auto-approved Luna session produced the CSV chart in one
|
||||
successful tool call. Canonical persistence, axes, legend, and reload were
|
||||
verified in the live isolated UI with no permission prompt or console error.
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
1. [x] [Record contract and decision](task-01-record-contract.md)
|
||||
2. [x] [Backend MCP contract](task-02-backend-mcp-contract.md)
|
||||
3. [x] [Frontend contract and dispatch](task-03-frontend-contract-dispatch.md)
|
||||
4. [x] [Native blocks and mobile parity](task-04-native-blocks-mobile.md)
|
||||
5. [x] [E2E and public documentation](task-05-e2e-public-docs.md)
|
||||
6. [x] [CSV-backed chart sources](task-06-csv-chart-sources.md)
|
||||
7. [x] [Chart axes and legends](task-07-chart-axes-legends.md)
|
||||
8. [x] [Real-agent ACP delivery](task-08-real-agent-acp-delivery.md)
|
||||
9. [x] [Compact MCP guidance](task-09-compact-mcp-guidance.md)
|
||||
10. [x] [Agent routing regression](task-10-agent-routing-regression.md)
|
||||
|
||||
Execution is sequential in the primary conversation. No subagents are
|
||||
authorized.
|
||||
|
||||
## Risks
|
||||
|
||||
- Historic malformed metadata must not crash transcript rendering.
|
||||
- Tool arguments arrive before server success; rich UI must wait for a
|
||||
successful terminal state.
|
||||
- Workspace files can outlive sessions but not task workspace cleanup.
|
||||
- Existing file reads can return up to 10 MiB. File previews never auto-fetch;
|
||||
CSV sources use a bounded one-time read capped at 256 KiB.
|
||||
- Multi-repository callback widening must preserve current desktop and mobile
|
||||
file-opening behavior.
|
||||
- CSV-backed charts must resolve once, reject source files over 256 KiB, and
|
||||
persist only a bounded normalized snapshot. Replay must never depend on the
|
||||
workspace file still existing or still containing the same rows.
|
||||
- Recharts discovers axes and tooltips from direct chart children. A wrapper
|
||||
component around those primitives silently removes tick values, grid lines,
|
||||
and hover/touch tooltips.
|
||||
- Codex ACP reports MCP invocations with the broad `execute` kind. Provider
|
||||
envelope recognition must be gated by explicit MCP metadata and must retain
|
||||
the actual tool name, arguments, and result without reclassifying ordinary
|
||||
shell execution.
|
||||
- JSON Schema examples alone may not be visible enough to weaker agents. Keep
|
||||
the system prompt recipe compact, exact, and synchronized across Task and
|
||||
Office surfaces.
|
||||
|
||||
## Axis and legend repair
|
||||
|
||||
Status: complete on 2026-08-15.
|
||||
|
||||
- Add browser assertions that line and bar charts render x/y tick values,
|
||||
preserve raw x values in tooltips, show a legend for every series count, and
|
||||
locally filter multi-series plots.
|
||||
- Replace the opaque `ChartAxes` component boundary in
|
||||
`apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx`
|
||||
with direct Recharts children so its discovery pass registers axes, grid,
|
||||
and tooltip behavior.
|
||||
- Add pure host-formatting helpers for compact numeric ticks, ISO date/time
|
||||
ticks, and bounded category labels. The underlying labels and values remain
|
||||
unchanged for replay and tooltip inspection.
|
||||
- Add an accessible legend that is informational for one series and uses
|
||||
pressed-state buttons for multiple series. Controls wrap inside the chart and
|
||||
retain 44px mobile hit targets.
|
||||
- Verify the focused desktop and mobile production-build E2E flows, web
|
||||
typecheck/lint, and the running isolated Tailscale gallery without touching
|
||||
the main `:9998` instance.
|
||||
|
||||
Result: Recharts now receives axes, grid, tooltip, and legend as direct chart
|
||||
children. Host formatting keeps raw replay data intact while showing compact
|
||||
dates, categories, and numbers. Every chart names its series; multi-series
|
||||
legends are 44px pressed-state controls that filter locally. The isolated
|
||||
desktop and Pixel 5 gallery rendered four axes and four legend controls with no
|
||||
horizontal overflow, while the main `:9998` process retained its original PID.
|
||||
|
||||
## Real-agent ACP delivery
|
||||
|
||||
Status: complete on 2026-08-16.
|
||||
|
||||
- Recognize only explicitly marked Codex MCP `execute` frames, preserve trusted
|
||||
provenance across the marker-less completion update, and persist canonical
|
||||
arguments plus the unwrapped MCP result.
|
||||
- Keep the frontend compatible with historic nested Codex payloads while new
|
||||
messages use the provider-neutral shape.
|
||||
- Give agents compact, exact inline and CSV chart recipes in Task and Office
|
||||
guidance plus schema examples; keep the MCP description focused on routing.
|
||||
- Validate selection, restraint, CSV authoring, automatic approval, canonical
|
||||
replay, axes, and legends with fresh real Luna sessions in an isolated
|
||||
Tailscale-accessible instance.
|
||||
|
||||
Result: Luna chose rich output for requested inline and CSV charts, retained
|
||||
Markdown for a small textual comparison, and completed the final CSV case on
|
||||
its first tool attempt. The isolated profile was full-access and auto-approved;
|
||||
no permission or clarification request occurred. The rendered line chart
|
||||
survived reload with five x-axis ticks, five y-axis ticks, and one legend.
|
||||
|
||||
## Instruction budget hardening
|
||||
|
||||
Status: complete on 2026-08-17.
|
||||
|
||||
- Cap the rich-output MCP description at 650 runes and each always-injected
|
||||
Task/Office guidance line at 750 runes.
|
||||
- Keep the exact inline and CSV recipes once in each prompt and in schema
|
||||
examples, but remove those payloads from the routing description.
|
||||
- Protect both budgets and the content split with focused backend tests.
|
||||
|
||||
Result: the tool description fell from 845 to 439 runes. Task and Office lines
|
||||
fell from 1,205 and 1,198 runes to 740 each without weakening mandatory
|
||||
explicit-request routing or removing either proven Luna recipe.
|
||||
|
||||
## Agent routing regression
|
||||
|
||||
Status: complete on 2026-08-19.
|
||||
|
||||
- Route explicit chart, preview, KPI, and metric-summary requests directly to
|
||||
`show_rich_output_kandev`; reject ASCII, SVG, HTML, and alternate-app
|
||||
presentation substitutes in the agent guidance.
|
||||
- Add one exact metric-group recipe while retaining the inline-chart, CSV-chart,
|
||||
Markdown-restraint, and host-ownership guidance under the existing budgets.
|
||||
- Re-run inline bar, CSV time series, file preview, metric summary, and small
|
||||
Markdown comparison prompts as fresh Luna Worker subtasks in separate
|
||||
worktree executor environments.
|
||||
|
||||
Result: the prompt lines are 737 of 750 runes and the MCP description is 454 of
|
||||
650 runes. All four native requests produced exactly one valid rich-output call;
|
||||
the textual comparison produced only Markdown. The CSV task created only its
|
||||
requested CSV source, and all five tasks completed without permission or input
|
||||
requests.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: "01-record-contract"
|
||||
title: "Record rich-output contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Record rich-output contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Product behavior, schema, mobile contract, failure modes, and persistence
|
||||
guarantees are recorded in one indexed feature spec.
|
||||
2. Host-native rendering and workspace-backed file ownership are recorded in
|
||||
an indexed accepted ADR.
|
||||
3. Implementation plan and sequential task files reflect the user-confirmed
|
||||
Kandev-native scope with no native table block.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
git diff --check -- docs/specs/agent-rich-output docs/plans/agent-rich-output docs/decisions/2026-08-14-kandev-native-agent-rich-output.md docs/specs/INDEX.md docs/decisions/INDEX.md
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
- `docs/specs/INDEX.md`
|
||||
- `docs/decisions/2026-08-14-kandev-native-agent-rich-output.md`
|
||||
- `docs/decisions/INDEX.md`
|
||||
- `docs/plans/agent-rich-output/plan.md`
|
||||
- `docs/plans/agent-rich-output/task-*.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task establishes the shared public and persistence contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- User-confirmed Kandev-native approach in the current task plan.
|
||||
- `docs/decisions/2026-08-04-file-backed-prompt-attachments.md`
|
||||
- `docs/decisions/2026-08-08-task-owned-worktree-lifetime.md`
|
||||
- `docs/decisions/2026-08-01-validate-mcp-tool-arguments.md`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report artifacts created, exact check result, remaining risks, and task/plan
|
||||
status update.
|
||||
|
||||
## Results
|
||||
|
||||
- Created and indexed the building feature spec, accepted ADR, implementation
|
||||
plan, and five sequential task files.
|
||||
- Recorded the host-native trust boundary, workspace-backed file ownership,
|
||||
exact version 1 contract, and the absence of a native table block.
|
||||
- `git diff --check -- docs/specs/agent-rich-output docs/plans/agent-rich-output docs/decisions/2026-08-14-kandev-native-agent-rich-output.md docs/specs/INDEX.md docs/decisions/INDEX.md` passed with exit code 0.
|
||||
- Security/trust boundary: agent data is plain semantic input; executable UI,
|
||||
remote resources, absolute paths, and inline file bytes remain excluded.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: "02-backend-mcp-contract"
|
||||
title: "Backend MCP contract"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-record-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Backend MCP contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Task and Office MCP profiles expose a strictly validated
|
||||
`show_rich_output_kandev`; configuration and external profiles do not.
|
||||
2. Version, union shape, count, size, numeric, and relative-path limits fail
|
||||
closed with clear tool errors.
|
||||
3. Task and Office prompts list the exact tool and instruct agents to prefer
|
||||
plain text or Markdown when rich output adds no value.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cd apps/backend && go test ./internal/mcp/server/...
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/server/rich_output.go`
|
||||
- `apps/backend/internal/mcp/server/rich_output_test.go`
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/server/server_test.go`
|
||||
- `apps/backend/config/prompts/kandev-context.md`
|
||||
- `apps/backend/config/prompts/office-context.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This creates the schema consumed by frontend tasks.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec API surface, permissions, and failure modes.
|
||||
- Existing `profileToolGroups`, `wrapHandler`, and Draft 7 validator patterns.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED and GREEN evidence, files changed, exact test result, trust-boundary
|
||||
notes, and task/plan status update.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: task/Office registration, semantic chart lengths, unsafe paths, and the
|
||||
64 KiB cap each failed before its implementation. The full package then
|
||||
exposed stale tool-count and prompt inventories.
|
||||
- GREEN: `go test ./internal/mcp/server/...` passed.
|
||||
- Added a closed Draft 7 schema, semantic validation, task/Office-only profile
|
||||
registration, exact prompt entries, and fail-closed contract coverage.
|
||||
- File references accept only task-workspace-relative paths. The handler stores
|
||||
no bytes and performs no file or backend mutation.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "03-frontend-contract-dispatch"
|
||||
title: "Frontend contract and dispatch"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-backend-mcp-contract"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Frontend contract and dispatch
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. A pure fail-closed parser accepts the version 1 file, chart, and metrics
|
||||
union and rejects malformed, over-limit, and forward-version payloads.
|
||||
2. Completed rich-output messages remain standalone while ordinary tool
|
||||
activity grouping is unchanged.
|
||||
3. Kandev dispatch gives the rich renderer current session and file-opening
|
||||
context without changing unregistered-tool fallback behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/task/chat/messages/kandev/rich-output/parse.test.ts components/task/chat/types.test.ts hooks/use-processed-messages.test.ts components/task/chat/messages/kandev-tool-message.test.tsx
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/types.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/parse.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/parse.test.ts`
|
||||
- `apps/web/components/task/chat/types.ts`
|
||||
- `apps/web/components/task/chat/types.test.ts`
|
||||
- `apps/web/hooks/use-processed-messages.ts`
|
||||
- `apps/web/hooks/use-processed-messages.test.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev-tool-message.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/registry.ts`
|
||||
- `apps/web/components/task/chat/message-renderer.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Parser and message identity are shared by native block rendering.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec API surface and state/failure scenarios.
|
||||
- Existing Kandev renderer registry and `groupActivityMessages` behavior.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED and GREEN evidence, actual files, exact focused test result, and
|
||||
task/plan status update.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the parser import was absent; rich tool calls collapsed into one
|
||||
activity group; the shared message predicates and host context were absent.
|
||||
- GREEN: 94 focused parser, predicate, grouping, dispatch, and existing Kandev
|
||||
renderer tests passed.
|
||||
- Added an exact version 1 parser with size/count/path/numeric checks, shared
|
||||
tool identity, standalone grouping, renderer registration, and session plus
|
||||
repository-aware file-opening context.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: "04-native-blocks-mobile"
|
||||
title: "Native blocks and mobile parity"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-frontend-contract-dispatch"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 04: Native blocks and mobile parity
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Successful calls render one restrained native presentation containing
|
||||
accessible file, line/bar chart, and metrics blocks; pending/error calls do
|
||||
not render unvalidated blocks.
|
||||
2. Files load only on explicit expansion and open through existing desktop and
|
||||
mobile file viewers with optional repository identity.
|
||||
3. Phone layout contains charts, reflows metrics, provides touch-safe actions,
|
||||
and adds no document horizontal overflow or hover-only capability.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/*.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/types.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev-tool-message.tsx`
|
||||
- `apps/web/components/task/chat/message-renderer.tsx`
|
||||
- `apps/web/components/task/chat/message-list-shared.tsx`
|
||||
- `apps/web/components/task/chat/message-list-native.tsx`
|
||||
- `apps/web/components/task/chat/messages/turn-group-message.tsx`
|
||||
- `apps/web/components/task/chat/use-chat-panel-state.ts`
|
||||
- `apps/web/components/task/task-chat-panel.tsx`
|
||||
- `apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx`
|
||||
- `apps/web/src/locales/en/task.json`
|
||||
- `apps/web/src/locales/pseudo/task.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 03.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Shared renderer context and file-open callbacks span both layouts.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec mobile design contract and file failure modes.
|
||||
- `KandevRow`/`KandevBody`, `@kandev/ui` chart primitives,
|
||||
`requestFileContent`, and `MobileFileViewerPanel` patterns.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report UI behavior, interface-polish choices, exact checks, actual files,
|
||||
remaining rendered-risk notes, and task/plan status update.
|
||||
|
||||
## Results
|
||||
|
||||
- Added a quiet standalone presentation plus responsive metric, accessible
|
||||
line/bar chart, and lazy file-preview blocks under
|
||||
`components/task/chat/messages/kandev/rich-output/`.
|
||||
- File content is requested only after **Preview**. Desktop, mobile, Office,
|
||||
and task-center callback paths now preserve optional repository identity for
|
||||
preview and native file opening.
|
||||
- Host tokens own chart colors; numeric metrics use tabular figures; images
|
||||
receive a neutral outline; phone actions are at least 44px and charts remain
|
||||
width-contained.
|
||||
- RED: the first live desktop E2E reached the localized unavailable state
|
||||
because ACP stores tool input inside `normalized.generic.input.raw_input`.
|
||||
A focused unit test reproduced that transport shape. GREEN: shared argument
|
||||
extraction unwraps it, and 95 focused tests pass.
|
||||
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and
|
||||
`pnpm run i18n:ratchet` pass. Pseudo and five real locale files contain the
|
||||
new host copy; existing catalog-parity notices remain advisory.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: "05-e2e-public-docs"
|
||||
title: "E2E and public documentation"
|
||||
status: done
|
||||
wave: 5
|
||||
depends_on: ["04-native-blocks-mobile"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 05: E2E and public documentation
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Desktop and `mobile-chrome` Playwright tests exercise the real inline
|
||||
Kandev MCP call, native rendering, file interaction, responsive geometry,
|
||||
and reload persistence.
|
||||
2. Public MCP reference documents availability, schema, limits, lifecycle,
|
||||
and plain-text-first guidance.
|
||||
3. Every task result and plan verification result records exact final commands
|
||||
and outcomes.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
cd apps/web
|
||||
pnpm e2e:run --project chromium tests/chat/rich-output.spec.ts -- --retries=0
|
||||
pnpm e2e:run --project mobile-chrome tests/chat/mobile-rich-output.spec.ts -- --retries=0
|
||||
cd ../..
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/chat/rich-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
- `docs/plans/agent-rich-output/*.md`
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 04.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Browser proof depends on final backend and frontend artifacts.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios and out-of-scope boundary.
|
||||
- Mock-agent `e2e:mcp:kandev:<tool>({...})` directive.
|
||||
- Existing chat and mobile file-viewer E2E patterns.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report discovered test counts, desktop/mobile outcomes, generated artifacts,
|
||||
cleanup evidence, docs validation, known risks, and synchronized task/plan
|
||||
status.
|
||||
|
||||
## Results
|
||||
|
||||
- Desktop Chromium: 1 rich-output test passed. It exercises the real mock-agent
|
||||
MCP directive, all three blocks, reload persistence, explicit preview, and
|
||||
the existing desktop file tab.
|
||||
- Mobile Chrome: 1 rich-output test passed. It proves zero document overflow,
|
||||
chart containment, 44px actions, explicit preview, and the native mobile
|
||||
viewer.
|
||||
- `docs/public/automation-and-mcp.md` now documents scope, the version 1
|
||||
contract and limits, file lifecycle, Markdown-table guidance, and the
|
||||
portable MCP versus MCP Apps boundary.
|
||||
- Focused E2E sleep lint passed. The public-doc test command passed 61 tests,
|
||||
and the page validator accepted all 41 published pages.
|
||||
- `git diff --check` passed. The E2E runner left no live process or
|
||||
`e2e/test-results` directory; Git status contains only intended source,
|
||||
test, locale, spec, plan, decision, and public-doc changes.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
id: "06-csv-chart-sources"
|
||||
title: "CSV-backed chart sources"
|
||||
status: done
|
||||
wave: 6
|
||||
depends_on: ["05-e2e-public-docs"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 06: CSV-backed chart sources
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Existing inline line and bar charts remain valid.
|
||||
2. A chart can name a workspace-relative CSV, x column, optional repository,
|
||||
and one to four numeric series columns without inlining row values.
|
||||
3. Handler rejects unsafe, binary, oversized, malformed, empty, or invalid CSV
|
||||
input with actionable errors and no completed rich presentation.
|
||||
4. Successful calls persist bounded normalized chart values in their MCP result.
|
||||
Reload renders that snapshot without reading the workspace again.
|
||||
5. Tool description, schema descriptions, task/Office prompts, and public docs
|
||||
make explicit chart, graph, plot, preview, bar comparison, and time-series
|
||||
use cases discoverable while retaining Markdown-table restraint.
|
||||
6. Desktop and phone E2E cover CSV-backed line and bar charts plus replay after
|
||||
source mutation or deletion.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Backend schema, resolver, snapshot, and prompt tests fail for behavior gaps.
|
||||
2. Implement bounded CSV resolution in `internal/mcp/server`.
|
||||
3. Frontend input/snapshot parser tests fail for replay gaps.
|
||||
4. Implement strict result overlay and structured-content extraction.
|
||||
5. Update real MCP E2E and public docs, then run focused and subtree checks.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
cd apps/backend
|
||||
go test ./internal/mcp/server/...
|
||||
cd ../web
|
||||
pnpm test -- components/task/chat/messages/kandev/rich-output components/task/chat/messages/kandev/parse.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:ratchet
|
||||
pnpm e2e:run --project chromium tests/chat/rich-output.spec.ts -- --retries=0
|
||||
pnpm e2e:run --project mobile-chrome tests/chat/mobile-rich-output.spec.ts -- --retries=0
|
||||
)
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/server/rich_output.go`
|
||||
- `apps/backend/internal/mcp/server/rich_output_test.go`
|
||||
- `apps/backend/internal/mcp/server/sysprompt_sync_test.go`
|
||||
- `apps/backend/config/prompts/kandev-context.md`
|
||||
- `apps/backend/config/prompts/office-context.md`
|
||||
- `apps/web/components/task/chat/messages/kandev/parse.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/types.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/parse.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/rich-output-renderer.tsx`
|
||||
- `apps/web/e2e/tests/chat/rich-output*.ts`
|
||||
- `docs/public/automation-and-mcp.md`
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing workspace reads are capped at 10 MiB, so reject response size before
|
||||
parsing and retain a tighter rich-output CSV cap of 256 KiB.
|
||||
- Require exact one-to-one snapshot entries by chart block index. Reject stale,
|
||||
duplicate, missing, or inline-targeting entries at the frontend boundary.
|
||||
- Return structured content and equivalent JSON text because ACP clients differ
|
||||
in how they retain MCP structured results.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the mutually exclusive `csv` chart source, bounded workspace read,
|
||||
strict CSV parser, and versioned normalized result snapshot. Inline charts
|
||||
remain unchanged.
|
||||
- Added fail-closed frontend snapshot overlay and ACP/standard MCP structured
|
||||
result extraction. Replay uses only persisted normalized values.
|
||||
- Added explicit tool/schema examples, task and Office authoring guidance, and
|
||||
public line/time-series and bar/comparison examples.
|
||||
- `go test ./internal/mcp/server/... -count=1`: passed.
|
||||
- `golangci-lint run ./internal/mcp/server/... --timeout=5m`: zero issues.
|
||||
- Focused rich-output Vitest: 3 files and 73 tests passed.
|
||||
- `pnpm run typecheck`, full `pnpm run lint`, `pnpm run i18n:check`, and
|
||||
`pnpm run i18n:ratchet`: passed. The i18n check retained only known advisory
|
||||
real-locale parity notices.
|
||||
- Desktop Chromium and mobile Chrome rich-output E2E: one test each passed.
|
||||
Desktop deleted the CSV before reload; both charts remained. Mobile showed
|
||||
both charts without page overflow and retained native file-viewer behavior.
|
||||
- Public docs: 61 validator tests and all 41 published pages passed.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: "07-chart-axes-legends"
|
||||
title: "Chart axes and legends"
|
||||
status: done
|
||||
wave: 7
|
||||
depends_on: ["06-csv-chart-sources"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 07: Chart axes and legends
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Inline and CSV-backed line/bar charts visibly render x- and y-axis tick
|
||||
values plus the existing grid and tooltip.
|
||||
2. ISO date/time x values and large y values receive restrained host-owned tick
|
||||
formatting; tooltips retain the original x value and full numeric values.
|
||||
3. One-series charts show their series label. Multi-series charts expose every
|
||||
label as a keyboard/touch legend filter with visible and accessible state.
|
||||
4. Legend controls wrap inside the chart, meet the 44px phone hit-target rule,
|
||||
and introduce no document-level horizontal overflow.
|
||||
5. Desktop and mobile production-build E2E prove axes, legends, filtering,
|
||||
tooltip data, containment, and replay.
|
||||
|
||||
## Root cause
|
||||
|
||||
`chart-block.tsx` placed `CartesianGrid`, `XAxis`, `YAxis`, and `ChartTooltip`
|
||||
inside a custom `ChartAxes` React component. Recharts 2.15 discovers those
|
||||
primitives by scanning a chart's direct children, so the wrapper prevented
|
||||
registration and produced a plot with no ticks, grid, or tooltip.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Extend focused desktop/mobile E2E with axis, single/multi legend, filtering,
|
||||
tooltip, touch-target, and containment assertions; run both to observe RED.
|
||||
2. Add failing pure tests for tick-formatting behavior.
|
||||
3. Implement direct chart children, formatting helpers, and local legend state.
|
||||
4. Re-run focused tests, typecheck, lint, and change-aware checks; inspect both
|
||||
desktop and Pixel 5 renderings in the isolated gallery.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-format.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-format.test.ts`
|
||||
- `apps/web/e2e/tests/chat/rich-output-helpers.ts`
|
||||
- `apps/web/e2e/tests/chat/rich-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts`
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
- `docs/plans/agent-rich-output/plan.md`
|
||||
|
||||
## Risks
|
||||
|
||||
- Axis formatting must not mutate persisted data or collapse distinct timestamp
|
||||
labels into indistinguishable ticks.
|
||||
- Recharts child discovery is structural; future refactors must keep axes,
|
||||
tooltip, and legend as direct chart children or fragments, not custom wrapper
|
||||
components.
|
||||
- Filtering must remain local presentation state and must not add callbacks or
|
||||
payload fields to the MCP contract.
|
||||
|
||||
## Results
|
||||
|
||||
- Removed the opaque `ChartAxes` component so Recharts discovers x/y axes,
|
||||
grid, and tooltip as direct chart children for both line and bar charts.
|
||||
- Added pure locale-aware formatting for compact ISO dates/times, bounded
|
||||
categories, large values, small decimals, and the pseudo-locale fallback.
|
||||
Persisted labels and tooltip values remain unchanged.
|
||||
- Added an informational one-series legend and 44px pressed-state buttons for
|
||||
multi-series charts. Toggling a button changes only local `hide` state and
|
||||
never writes to the MCP payload or calls a tool.
|
||||
- RED browser proof found zero axis text on both desktop and Pixel 5. GREEN
|
||||
desktop and mobile production runs passed with x/y tick assertions, raw
|
||||
tooltip values, six-to-three bar filtering, replay, containment, and native
|
||||
file opening.
|
||||
- Verification passed: 62 focused frontend tests, backend MCP tests, typecheck,
|
||||
full web lint, i18n check/ratchet, focused E2E sleep lint, both focused E2E
|
||||
projects, 61 public-doc validator tests, and all 41 published pages.
|
||||
- The isolated Tailscale gallery passed desktop and phone inspection with four
|
||||
axes, four 44px legend controls, working filter/restore, and zero horizontal
|
||||
overflow. The main `:9998` instance was not restarted or modified.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
id: "08-real-agent-acp-delivery"
|
||||
title: "Real-agent ACP delivery"
|
||||
status: complete
|
||||
wave: 8
|
||||
depends_on: ["07-chart-axes-legends"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 08: Real-agent ACP delivery
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. A captured Codex ACP MCP frame with `kind: execute` and
|
||||
`_meta.is_mcp_tool_call: true` persists as a generic tool call named for the
|
||||
actual MCP tool, not as an empty shell execution.
|
||||
2. Initial arguments and the completed MCP `CallToolResult` survive live
|
||||
delivery and replay in the provider-neutral normalized payload.
|
||||
3. The frontend tolerates both the corrected normalized payload and historic
|
||||
Codex wrappers without rendering pending, failed, or malformed data.
|
||||
4. Task and Office guidance provides an exact compact inline chart recipe,
|
||||
preserves the CSV recipe, and states that Kandev owns axes and legends.
|
||||
5. A fresh isolated Luna profile uses full-access mode and automatic approval;
|
||||
real-agent trials create valid inline bar, CSV/time-series, and restrained
|
||||
Markdown results without permission prompts.
|
||||
6. Existing desktop and mobile rich-output flows remain behaviorally equal and
|
||||
pass their focused production-build E2E coverage. This repair changes
|
||||
transport normalization and guidance, not mobile composition.
|
||||
|
||||
## Root causes
|
||||
|
||||
- Codex ACP wraps MCP calls in the standard ACP `execute` category and carries
|
||||
`{server, tool, arguments}` in `rawInput`. The adapter normalized solely from
|
||||
the category, losing the actual MCP identity and treating the presentation as
|
||||
shell execution. Its completion wrapper also nested the MCP result beneath
|
||||
`rawOutput.result`.
|
||||
- The rich-output schema was strict, but the injected agent prompt did not show
|
||||
a canonical inline chart object. Luna selected the correct tool yet invented
|
||||
`data`, `categories`, and axis fields until the prompt supplied the exact
|
||||
`labels` plus `series[].values` recipe.
|
||||
- Earlier isolated sessions inherited a read-only Luna profile. Enabling
|
||||
automatic approval later could not rewrite already-running session policy.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Add a backend regression using the captured initial and completed Codex ACP
|
||||
envelopes; observe shell classification and lost result as RED.
|
||||
2. Add frontend parser regressions for the real nested `raw_input.arguments`
|
||||
and `rawOutput.result.structuredContent` shapes; observe RED.
|
||||
3. Add prompt/schema contract assertions for the exact inline chart recipe and
|
||||
host-owned axes/legend guidance; observe RED.
|
||||
4. Implement Codex-dialect MCP recognition and result unwrapping, frontend
|
||||
compatibility parsing, and synchronized compact authoring guidance.
|
||||
5. Rebuild only the isolated test environment, configure a new full-access,
|
||||
auto-approved Luna profile, and run fresh real-agent scenarios.
|
||||
6. Run focused Go/Vitest checks plus existing desktop and mobile rich-output
|
||||
E2E. Record commands, counts, agent outcomes, and isolated-instance details.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go`
|
||||
- `apps/backend/internal/agentctl/server/adapter/transport/acp/codex_mcp_tool_test.go`
|
||||
- `apps/backend/internal/mcp/server/rich_output.go`
|
||||
- `apps/backend/internal/mcp/server/rich_output_test.go`
|
||||
- `apps/backend/internal/mcp/server/sysprompt_sync_test.go`
|
||||
- `apps/backend/config/prompts/kandev-context.md`
|
||||
- `apps/backend/config/prompts/office-context.md`
|
||||
- `apps/web/components/task/chat/messages/kandev/parse.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/parse.test.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/kandev-tool-message.test.tsx`
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
- `docs/plans/agent-rich-output/plan.md`
|
||||
|
||||
## Risks
|
||||
|
||||
- The recognizer must require Codex's explicit MCP marker and a complete
|
||||
envelope so ordinary `execute` calls remain shell cards.
|
||||
- Incremental completion updates must not merge the provider wrapper back into
|
||||
the canonical arguments.
|
||||
- Result unwrapping must preserve standard MCP text, structured content, and
|
||||
error information without trusting arbitrary nested objects as rich output.
|
||||
- Prompt recipes must be exact enough to use while remaining compact enough
|
||||
not to burden every task turn.
|
||||
- Isolated profile changes affect only newly created sessions. Evidence must
|
||||
come from fresh tasks after the profile update.
|
||||
|
||||
## Results
|
||||
|
||||
Completed on 2026-08-16.
|
||||
|
||||
- Codex's explicitly marked initial MCP `execute` frame now establishes
|
||||
trusted, in-memory MCP provenance. The terminal frame can omit the marker,
|
||||
as observed in the live ACP capture, without merging `rawInput` back into
|
||||
canonical arguments or retaining the outer `{error, result}` wrapper.
|
||||
- Historic Codex wrappers remain readable in the frontend while new messages
|
||||
persist the provider-neutral tool name, arguments, and `CallToolResult`.
|
||||
- Task and Office guidance plus MCP schema examples include complete inline and
|
||||
CSV chart recipes. The focused tool description routes explicit requests;
|
||||
the prompts include the required block summary and host-owned axes/legends.
|
||||
- The isolated profile `Luna Worker (full access)` used model
|
||||
`gpt-5.6-luna`, mode `agent-full-access`, and `auto_approve: true`.
|
||||
- Real Luna trials produced: one valid inline bar chart on its only rich-output
|
||||
attempt; one Markdown table with no rich-output call; and one CSV line chart
|
||||
on its only rich-output attempt after the recipe repair. The final CSV trial
|
||||
had zero failed calls, zero input requests, and zero permission messages.
|
||||
- The final CSV tool message contained no `raw_input` field and no outer Codex
|
||||
result envelope. Browser verification found one visible line chart, five
|
||||
x-axis ticks, five y-axis ticks, one legend, and zero console errors before
|
||||
and after reload.
|
||||
- `go test ./internal/agentctl/... ./internal/mcp/...`: passed.
|
||||
- Focused rich-output/settings Vitest run: 10 files and 86 tests passed.
|
||||
- `pnpm run typecheck` and `pnpm run i18n:check`: passed; existing real-locale
|
||||
parity findings remained advisory and pseudo stayed synchronized.
|
||||
- Desktop Chromium rich-output E2E: 2 tests passed. Mobile Chrome rich-output
|
||||
E2E: 1 test passed.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "09-compact-mcp-guidance"
|
||||
title: "Compact MCP guidance"
|
||||
status: complete
|
||||
wave: 9
|
||||
depends_on: ["08-real-agent-acp-delivery"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 09: Compact MCP guidance
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. The rich-output tool description remains discoverable but is at most 650
|
||||
runes and does not duplicate full inline or CSV payloads.
|
||||
2. Task and Office guidance each retain mandatory explicit-request routing and
|
||||
exactly one proven inline recipe and CSV recipe in at most 750 runes.
|
||||
3. JSON Schema examples continue to expose complete inline and CSV calls for
|
||||
clients that surface them.
|
||||
4. The shared MCP metadata budget and focused prompt tests prevent description,
|
||||
prompt-budget, and recipe-placement drift.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Add failing description and prompt budget/placement assertions.
|
||||
2. Reduce the tool description to routing and host-ownership guidance.
|
||||
3. Deduplicate both prompts while preserving the exact Luna-proven recipes.
|
||||
4. Run the focused MCP server tests, then the complete MCP server package.
|
||||
|
||||
## Results
|
||||
|
||||
Completed on 2026-08-17.
|
||||
|
||||
- The MCP description is 439 runes, down from 845, under the 650-rune budget.
|
||||
- `show_rich_output_kandev` is registered in the shared core-tool description
|
||||
budget introduced by PR #2716.
|
||||
- Task and Office rich-output lines are each 740 runes, down from 1,205 and
|
||||
1,198, under the 750-rune budget.
|
||||
- Both prompts still say agents must use rich output for explicit suitable
|
||||
requests, retain one exact inline and CSV block, and explain host ownership.
|
||||
- Schema examples remain unchanged.
|
||||
- `go test ./internal/mcp/... -count=1`: passed.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: "10-agent-routing-regression"
|
||||
title: "Agent routing regression"
|
||||
status: complete
|
||||
wave: 10
|
||||
depends_on: ["09-compact-mcp-guidance"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 10: Agent routing regression
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Explicit chart, graph, plot, file-preview, KPI-card, and native metric-summary
|
||||
requests route directly to `show_rich_output_kandev` when suitable data is
|
||||
available.
|
||||
2. Agent guidance forbids ASCII, SVG, HTML, and alternate-app presentation
|
||||
substitutes while retaining Markdown for small textual tables.
|
||||
3. Task and Office prompts contain exact inline-chart, CSV-chart, and metric-group
|
||||
recipes in at most 750 runes; the MCP description remains at most 650 runes.
|
||||
4. Fresh full-access, auto-approved Luna Worker subtasks pass inline bar, CSV
|
||||
time-series, file-preview, metric-summary, and Markdown-restraint scenarios
|
||||
in separate worktree executor environments without input requests.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Add failing prompt and tool-description assertions for direct routing,
|
||||
presentation-substitute rejection, the metric recipe, and existing budgets.
|
||||
2. Replace weaker routing text with the compact direct-call contract in both
|
||||
injected prompts and the MCP tool description.
|
||||
3. Run the focused tests and complete MCP package, then rebuild only the
|
||||
disposable seed instance.
|
||||
4. Create fresh Luna Worker subtasks under a worktree-backed evaluation parent,
|
||||
inspect their canonical transcripts, and verify workspace isolation.
|
||||
|
||||
## Results
|
||||
|
||||
Completed on 2026-08-19.
|
||||
|
||||
- Focused tests failed before the guidance change and passed afterward.
|
||||
- `go test ./internal/mcp/... -count=1`: passed.
|
||||
- Task and Office rich-output lines are 737 runes; the tool description is 454
|
||||
runes. Existing 750/650-rune limits remain unchanged.
|
||||
- Inline bar, CSV time series, file preview, and KPI summary each made exactly
|
||||
one valid `show_rich_output_kandev` call. The small comparison made none and
|
||||
returned a Markdown table.
|
||||
- The CSV task created `reports/luna-latency.csv` and no HTML or SVG substitute.
|
||||
All five trials had zero permission or clarification requests.
|
||||
- Every task used executor `exec-worktree` and received a distinct workspace.
|
||||
The isolated seed remained on ports `55081`/`55082`; the main instance on
|
||||
`9998` remained healthy and unchanged.
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
spec: docs/specs/agent-rich-output/spec.md
|
||||
created: 2026-08-16
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Fix Plan: Rich-output Chart Performance
|
||||
|
||||
## Outcome
|
||||
|
||||
Keep the existing Recharts line and bar entrance animations, but spend that
|
||||
work only on a chart the user is actually viewing. Charts in background tabs
|
||||
or below the transcript viewport defer their plot, unchanged messages do not
|
||||
rebuild or restart plots, and users can disable rich-output animation per
|
||||
device. Axes, tooltips, legends, CSV replay, and desktop/mobile composition stay
|
||||
unchanged.
|
||||
|
||||
## Confirmed evidence and revised direction
|
||||
|
||||
The isolated application contains no performance fix; it is the before case.
|
||||
It feels smoother after Vite/browser caches warm, after charts settle, and now
|
||||
that the profiling Chromium process is closed.
|
||||
|
||||
The current `ChartBlock` uses Recharts 2.15.4 defaults: `Line` animates for
|
||||
1,500 ms and `Bar` for 400 ms. A foreground harness measured 97 React commits
|
||||
and 211 animated SVG attribute writes through roughly 1.9 seconds for one
|
||||
10-point/two-series chart. Four maximum-density charts produced 5,004 DOM
|
||||
mutation records and a 1.52-second frame gap. Those animations are intentionally
|
||||
retained for a visible chart by user direction.
|
||||
|
||||
The avoidable work is concurrency and repetition:
|
||||
|
||||
- Every chart plot mounts immediately, including plots below the scrollport or
|
||||
in a background tab, so several examples can animate concurrently without
|
||||
being seen.
|
||||
- `chart-block.tsx` rebuilds chart data, configuration, formatters, callbacks,
|
||||
and inline object/array props on every parent render.
|
||||
- `rich-output-renderer.tsx` reparses stable persisted values, recreating block
|
||||
identities and preventing a memoized chart boundary from helping.
|
||||
- There is no explicit per-device animation opt-out, and Recharts does not
|
||||
automatically honor the operating system's reduced-motion preference.
|
||||
|
||||
Settled charts do not have an infinite idle loop. Separate development tabs
|
||||
also duplicate the full SPA's memory, so final claims use warmed production
|
||||
builds and equivalent single-tab/background-tab comparisons.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Wave 1: schedule each plot once and stabilize rerenders
|
||||
|
||||
- Add a small visibility hook beside the chart renderer. It observes both
|
||||
`document.visibilityState` and `IntersectionObserver` with a modest prewarm
|
||||
margin. A chart becomes eligible only when its block is near the viewport in
|
||||
the visible tab.
|
||||
- Render the title, summary, and a fixed-height plot placeholder before
|
||||
eligibility so transcript geometry and accessible context stay stable.
|
||||
- Once eligible, mount the real plot permanently. Do not unmount it when the
|
||||
user scrolls away; that would replay the entrance animation on return.
|
||||
- Keep `isAnimationActive` at its Recharts default when motion is enabled.
|
||||
- Memoize `parseRichOutput(args, result)`, `chartData`, `chartConfig`, x/y
|
||||
formatters, legend callbacks, and immutable chart props around their true
|
||||
dependencies. Memoize `ChartBlock` so an unchanged block does not rerender
|
||||
with its parent.
|
||||
- Preserve axes, grid, tooltip, and legend as direct Recharts children because
|
||||
Recharts discovers them structurally.
|
||||
|
||||
### Wave 2: add the per-device motion opt-out
|
||||
|
||||
- Store one boolean preference in local storage, defaulting to enabled. Follow
|
||||
the existing `settingsMenu` preview/commit/restore pattern in the UI Zustand
|
||||
slice rather than adding an account setting or backend field: performance and
|
||||
motion preferences differ by device, and the value must be available before
|
||||
a chart mounts.
|
||||
- Add one compact card to the existing Appearance section, adjacent to the
|
||||
color theme rather than creating another settings page. Use the shared save
|
||||
coordinator, a touch-safe `Switch`, explicit explanatory copy, and settings
|
||||
discovery metadata.
|
||||
- Compute effective chart motion as saved/previewed device preference AND not
|
||||
`prefers-reduced-motion: reduce`. Subscribe to media-query changes so an OS
|
||||
preference change takes effect without reload.
|
||||
- Pass the effective value to every Recharts `Line` and `Bar` through
|
||||
`isAnimationActive`. When enabled, retain the library's current style and
|
||||
duration exactly; when disabled, render complete geometry immediately.
|
||||
|
||||
## Files
|
||||
|
||||
### Chart scheduling and rerender stability
|
||||
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-visibility.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.test.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-motion.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-motion.test.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/rich-output-renderer.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/rich-output-renderer.test.tsx`
|
||||
- `apps/web/e2e/tests/chat/rich-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts`
|
||||
|
||||
### Per-device animation preference
|
||||
|
||||
- `apps/web/lib/settings/rich-output-motion.ts`
|
||||
- `apps/web/lib/settings/rich-output-motion.test.ts`
|
||||
- `apps/web/lib/settings/constants.ts`
|
||||
- `apps/web/lib/state/slices/ui/rich-output-motion-actions.ts`
|
||||
- `apps/web/lib/state/slices/ui/rich-output-motion-actions.test.ts`
|
||||
- `apps/web/lib/state/slices/ui/types.ts`
|
||||
- `apps/web/lib/state/slices/ui/ui-slice.ts`
|
||||
- `apps/web/lib/state/store.ts`
|
||||
- `apps/web/components/settings/rich-output-motion-settings-card.tsx`
|
||||
- `apps/web/components/settings/rich-output-motion-settings-card.test.tsx`
|
||||
- `apps/web/components/settings/appearance-settings-state.ts`
|
||||
- `apps/web/components/settings/appearance-account-sections.tsx`
|
||||
- `apps/web/components/settings/general-settings.tsx`
|
||||
- `apps/web/components/settings/general-settings.test.tsx`
|
||||
- `apps/web/lib/settings-discovery/catalog/preferences.ts`
|
||||
- `apps/web/lib/settings-discovery/catalog.test.ts`
|
||||
- `apps/web/src/locales/en/settings.json`
|
||||
- `apps/web/src/locales/pseudo/settings.json`
|
||||
- `apps/web/e2e/tests/settings/settings-manual-save.spec.ts`
|
||||
- `apps/web/e2e/tests/settings/mobile-general-settings.spec.ts`
|
||||
|
||||
### Durable artifacts
|
||||
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
- `docs/plans/rich-output-chart-performance/plan.md`
|
||||
- `docs/plans/rich-output-chart-performance/task-01-defer-and-stabilize-charts.md`
|
||||
- `docs/plans/rich-output-chart-performance/task-02-rich-output-motion-setting.md`
|
||||
|
||||
No backend, MCP schema, database, persisted tool payload, dependency, or public
|
||||
documentation change is required.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
The completed presentation remains inline in the same phone transcript. A
|
||||
deferred plot reserves its existing height, so scrolling does not jump when it
|
||||
mounts. Near-viewport charts animate once by default; offscreen charts wait.
|
||||
Legend controls retain 44-pixel targets and the transcript remains the sole
|
||||
vertical scroll owner.
|
||||
|
||||
The Appearance control uses the same settings page and shared floating Save
|
||||
surface on desktop and mobile. Its row wraps explanatory text without
|
||||
horizontal overflow, keeps a 44-pixel switch target, and requires no phone-only
|
||||
modal or navigation branch.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from `apps/web` unless noted:
|
||||
|
||||
1. `pnpm exec vitest run components/task/chat/messages/kandev/rich-output/chart-block.test.tsx components/task/chat/messages/kandev/rich-output/chart-motion.test.tsx components/task/chat/messages/kandev/rich-output/rich-output-renderer.test.tsx components/task/chat/messages/kandev-tool-message.test.tsx`
|
||||
2. `pnpm exec vitest run lib/settings/rich-output-motion.test.ts lib/state/slices/ui/rich-output-motion-actions.test.ts components/settings/rich-output-motion-settings-card.test.tsx components/settings/general-settings.test.tsx lib/settings-discovery/catalog.test.ts`
|
||||
3. `pnpm run typecheck`
|
||||
4. `pnpm run lint`
|
||||
5. `pnpm run i18n:check`
|
||||
6. `pnpm run i18n:ratchet`
|
||||
7. `pnpm e2e:run --no-build --project chromium tests/chat/rich-output.spec.ts tests/settings/settings-manual-save.spec.ts`
|
||||
8. `pnpm e2e:run --no-build --project mobile-chrome tests/chat/mobile-rich-output.spec.ts tests/settings/mobile-general-settings.spec.ts`
|
||||
9. `pnpm run lint:e2e-sleeps -- e2e/tests/chat/rich-output.spec.ts e2e/tests/chat/mobile-rich-output.spec.ts e2e/tests/settings/settings-manual-save.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts`
|
||||
10. From the repository root: `git diff --check`
|
||||
|
||||
## Waves
|
||||
|
||||
1. [x] [Defer and stabilize charts](task-01-defer-and-stabilize-charts.md)
|
||||
2. [x] [Add rich-output motion setting](task-02-rich-output-motion-setting.md)
|
||||
|
||||
Both waves are sequential because they share `chart-block.tsx`, the performance
|
||||
harness, and final browser measurements. No subagents are authorized.
|
||||
|
||||
## Performance evaluation
|
||||
|
||||
The warmed diagnostic stress presentation contained four 100-point,
|
||||
four-series charts. Before the change, all four plots mounted together, with
|
||||
5,004 chart DOM mutation records and an approximately 1.52-second maximum frame
|
||||
gap. After the change, only two near-viewport plots mounted initially, with
|
||||
3,415 chart mutation records and an approximately 1.15-second maximum frame
|
||||
gap. These development-browser samples are directional rather than a CI
|
||||
threshold because their instrumentation scopes are not perfectly identical.
|
||||
|
||||
With the per-device setting disabled, the same browser produced complete line
|
||||
geometry without Recharts' animated dash attribute and 1,758 chart mutation
|
||||
records in the sampled reload. The preference was restored to enabled after
|
||||
measurement.
|
||||
|
||||
Headless Chromium reports every page as `document.visibilityState = "visible"`,
|
||||
even after another page is brought to the foreground, so it cannot prove the
|
||||
real background-tab branch. Unit coverage directly exercises hidden-document
|
||||
intersection, later visibility, one-time mounting, and unsupported API
|
||||
fallback. Desktop and mobile E2E cover real Recharts animation, the static
|
||||
device opt-out, viewport eligibility, persistence, and touch geometry.
|
||||
|
||||
Final verification passed: 66 focused unit tests, TypeScript typecheck, full
|
||||
workspace lint, both i18n checks, E2E sleep lint, the E2E production build,
|
||||
8 desktop E2E cases, 7 mobile E2E cases, and `git diff --check`.
|
||||
|
||||
## Risks and considerations
|
||||
|
||||
- `IntersectionObserver` and Page Visibility events can race on initial load.
|
||||
Eligibility must be monotonic, and unsupported APIs must fall back to
|
||||
immediate rendering rather than a permanent placeholder.
|
||||
- A deferred placeholder must preserve chart height and accessible title/
|
||||
summary without pretending an unmounted plot is interactive.
|
||||
- Memo dependencies must include locale and payload changes; stale ticks or
|
||||
values are worse than extra work.
|
||||
- Default animation must remain unchanged for visible charts. The opt-out and
|
||||
OS reduced-motion path are the only reasons to pass
|
||||
`isAnimationActive={false}`.
|
||||
- Dense SVG retains a one-time visible mount cost. Data sampling, lower limits,
|
||||
canvas rendering, and changed animation duration remain out of scope.
|
||||
- The local preference is intentionally device-owned. It does not sync across
|
||||
browsers and must not be added to the user-settings API accidentally.
|
||||
|
||||
The record review found no ADR is needed: this follows the established
|
||||
per-device Appearance preference boundary rather than creating a new ownership
|
||||
model.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
id: "01-defer-and-stabilize-charts"
|
||||
title: "Defer and stabilize charts"
|
||||
status: completed
|
||||
wave: 1
|
||||
parallelism: sequential
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Defer and stabilize charts
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. A chart plot below the viewport or in a background tab does not mount. Its
|
||||
title, summary, and fixed plot space remain in the transcript.
|
||||
2. A near-viewport chart in the visible tab mounts once and retains the current
|
||||
Recharts line/bar animation defaults. Scrolling away/back or rerendering an
|
||||
unchanged parent never remounts or restarts it.
|
||||
3. Parsed presentations, chart data/configuration, axis formatters, callbacks,
|
||||
and immutable props retain identity for unchanged dependencies. A legend
|
||||
toggle changes only local series visibility.
|
||||
4. Missing `IntersectionObserver` or Page Visibility support falls back to an
|
||||
immediately usable chart.
|
||||
5. Desktop and Pixel 5 flows retain axes, raw tooltip values, keyboard/touch
|
||||
legend filtering, CSV replay, containment, and file opening.
|
||||
6. The isolated multi-tab profile shows that background examples and offscreen
|
||||
plots no longer mount/animate concurrently. Results are recorded below.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Add visibility-hook tests for background documents, offscreen blocks,
|
||||
intersection, visibility changes, monotonic mount eligibility, cleanup, and
|
||||
unsupported browser APIs. Observe RED because plots currently mount
|
||||
immediately.
|
||||
2. Add a mocked chart component regression proving derived inputs churn across
|
||||
a parent rerender/legend toggle and proving the default animation prop is
|
||||
retained after the change.
|
||||
3. Implement visible-near-viewport mount-once scheduling, stable placeholder
|
||||
geometry, parsed-output memoization, chart prop memoization, and the memoized
|
||||
component boundary.
|
||||
4. Update desktop/mobile E2E to scroll deferred charts into view before
|
||||
interaction and prove they still animate only when eligible.
|
||||
5. Run focused checks and repeat single-chart, maximum-density, and multi-tab
|
||||
measurements in the isolated environment.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-visibility.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.test.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/rich-output-renderer.test.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/rich-output-renderer.tsx`
|
||||
- `apps/web/e2e/tests/chat/rich-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts`
|
||||
- `docs/plans/rich-output-chart-performance/task-01-defer-and-stabilize-charts.md`
|
||||
|
||||
## Targeted commands
|
||||
|
||||
Run from `apps/web`:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run components/task/chat/messages/kandev/rich-output/chart-block.test.tsx components/task/chat/messages/kandev/rich-output/rich-output-renderer.test.tsx components/task/chat/messages/kandev-tool-message.test.tsx
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm e2e:raw --project=chromium e2e/tests/chat/rich-output.spec.ts
|
||||
pnpm e2e:raw --project=mobile-chrome e2e/tests/chat/mobile-rich-output.spec.ts
|
||||
pnpm run lint:e2e-sleeps -- e2e/tests/chat/rich-output.spec.ts e2e/tests/chat/mobile-rich-output.spec.ts
|
||||
```
|
||||
|
||||
Then run `git diff --check` from the repository root.
|
||||
|
||||
## Risks
|
||||
|
||||
- Initial observer/visibility ordering must not strand a chart placeholder.
|
||||
- A once-mounted plot must never unmount merely because it leaves the viewport.
|
||||
- Recharts direct-child discovery prevents hiding axes/tooltip/legend behind a
|
||||
custom wrapper.
|
||||
- Chart data or locale changes must invalidate only the relevant memoized
|
||||
values and still update the mounted plot.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented monotonic, visible-near-viewport plot eligibility with a 200-pixel
|
||||
prewarm margin and immediate fallback when Intersection Observer is absent.
|
||||
Mounted plots stay mounted. Parsed output, chart data/configuration, formatters,
|
||||
callbacks, and the chart component boundary now retain stable identities.
|
||||
|
||||
The warmed four-chart stress case changed from four plots mounting together to
|
||||
two initial near-viewport plots. Chart mutation records fell from 5,004 to
|
||||
3,415, and the sampled maximum frame gap changed from approximately 1.52 to
|
||||
1.15 seconds. These values are diagnostic development measurements, not a
|
||||
performance budget.
|
||||
|
||||
Headless Chromium cannot emulate this branch because it keeps all pages
|
||||
`visible`; direct unit coverage verifies hidden-document deferral, later
|
||||
visibility, mount-once behavior, and observer fallback. Desktop and mobile E2E
|
||||
verify eligibility while retaining axes, legends, CSV replay, containment, and
|
||||
file behavior.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
id: "02-rich-output-motion-setting"
|
||||
title: "Add rich-output motion setting"
|
||||
status: completed
|
||||
wave: 2
|
||||
parallelism: sequential
|
||||
depends_on: ["01-defer-and-stabilize-charts"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/agent-rich-output/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Add rich-output motion setting
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Appearance contains one searchable, localized `Animate rich-output charts`
|
||||
switch with concise scope/performance copy and a 44-pixel touch target.
|
||||
2. The preference defaults on, previews immediately, persists only when the
|
||||
shared Save changes action succeeds, restores on Reset, survives reload, and
|
||||
is owned by the current browser/device rather than the account API.
|
||||
3. Enabled charts retain the existing Recharts animation style and duration.
|
||||
Disabled charts pass `isAnimationActive={false}` to every line/bar series.
|
||||
4. `prefers-reduced-motion: reduce` disables effective chart animation even
|
||||
when the saved switch is on, and a live media-query change updates future
|
||||
plots without reload.
|
||||
5. Desktop and mobile settings remain contained and reachable; existing chart
|
||||
axes, tooltips, legends, replay, and file behavior are unchanged.
|
||||
|
||||
## TDD order
|
||||
|
||||
1. Add failing storage/state tests for default, malformed storage, preview,
|
||||
commit, restore, and per-device persistence.
|
||||
2. Add failing Appearance tests for dirty state, shared Save/Reset, discovery,
|
||||
and explanatory copy.
|
||||
3. Add failing chart tests for enabled, explicit-disabled, and OS
|
||||
reduced-motion animation props.
|
||||
4. Implement storage helpers, UI-slice state/actions, Appearance card/save
|
||||
integration, effective motion hook, translations, and chart wiring.
|
||||
5. Add desktop/mobile E2E for save/reload, 44-pixel geometry, no overflow, and
|
||||
actual-Recharts animation/static behavior.
|
||||
6. Run focused checks and profile the animation-disabled isolated scenario.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/lib/settings/rich-output-motion.ts`
|
||||
- `apps/web/lib/settings/rich-output-motion.test.ts`
|
||||
- `apps/web/lib/settings/constants.ts`
|
||||
- `apps/web/lib/state/slices/ui/rich-output-motion-actions.ts`
|
||||
- `apps/web/lib/state/slices/ui/rich-output-motion-actions.test.ts`
|
||||
- `apps/web/lib/state/slices/ui/types.ts`
|
||||
- `apps/web/lib/state/slices/ui/ui-slice.ts`
|
||||
- `apps/web/lib/state/store.ts`
|
||||
- `apps/web/components/settings/rich-output-motion-settings-card.tsx`
|
||||
- `apps/web/components/settings/rich-output-motion-settings-card.test.tsx`
|
||||
- `apps/web/components/settings/appearance-settings-state.ts`
|
||||
- `apps/web/components/settings/appearance-account-sections.tsx`
|
||||
- `apps/web/components/settings/general-settings.tsx`
|
||||
- `apps/web/components/settings/general-settings.test.tsx`
|
||||
- `apps/web/lib/settings-discovery/catalog/preferences.ts`
|
||||
- `apps/web/lib/settings-discovery/catalog.test.ts`
|
||||
- `apps/web/src/locales/en/settings.json`
|
||||
- `apps/web/src/locales/pseudo/settings.json`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-block.test.tsx`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-motion.ts`
|
||||
- `apps/web/components/task/chat/messages/kandev/rich-output/chart-motion.test.tsx`
|
||||
- `apps/web/e2e/tests/settings/settings-manual-save.spec.ts`
|
||||
- `apps/web/e2e/tests/settings/mobile-general-settings.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/rich-output.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-rich-output.spec.ts`
|
||||
- `docs/specs/agent-rich-output/spec.md`
|
||||
- `docs/plans/rich-output-chart-performance/plan.md`
|
||||
- `docs/plans/rich-output-chart-performance/task-02-rich-output-motion-setting.md`
|
||||
|
||||
## Targeted commands
|
||||
|
||||
Run from `apps/web`:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run lib/settings/rich-output-motion.test.ts lib/state/slices/ui/rich-output-motion-actions.test.ts components/settings/rich-output-motion-settings-card.test.tsx components/settings/general-settings.test.tsx lib/settings-discovery/catalog.test.ts components/task/chat/messages/kandev/rich-output/chart-block.test.tsx components/task/chat/messages/kandev/rich-output/chart-motion.test.tsx
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:ratchet
|
||||
pnpm e2e:raw --project=chromium e2e/tests/chat/rich-output.spec.ts e2e/tests/settings/settings-manual-save.spec.ts
|
||||
pnpm e2e:raw --project=mobile-chrome e2e/tests/chat/mobile-rich-output.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts
|
||||
pnpm run lint:e2e-sleeps -- e2e/tests/chat/rich-output.spec.ts e2e/tests/chat/mobile-rich-output.spec.ts e2e/tests/settings/settings-manual-save.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts
|
||||
```
|
||||
|
||||
Then run `git diff --check` from the repository root.
|
||||
|
||||
## Risks
|
||||
|
||||
- A per-device setting must never leak into backend user-setting requests.
|
||||
- Preview/save/discard behavior must stay consistent with theme and settings
|
||||
menu mode, including edits made while Save is in flight.
|
||||
- OS reduced motion is an accessibility override, not a saved preference; the
|
||||
switch continues to represent the device preference clearly.
|
||||
- User-facing text must describe only rich-output charts, not imply that every
|
||||
Kandev animation is disabled.
|
||||
|
||||
## Results
|
||||
|
||||
Added a default-on device-local Appearance preference with preview, shared
|
||||
Save/Reset coordination, reload persistence, settings discovery, localized
|
||||
copy, and a 44-pixel switch target. Saving only this preference does not call
|
||||
the account settings API. The effective motion hook shares one live media-query
|
||||
subscription and always lets `prefers-reduced-motion` override the device
|
||||
choice.
|
||||
|
||||
Recharts lines and bars retain their native animation defaults when effective
|
||||
motion is enabled. The disabled browser sample rendered the line without an
|
||||
animated dash attribute and recorded 1,758 chart mutations. Unit coverage
|
||||
exercises storage fallback, state transitions, OS changes, and chart props;
|
||||
desktop and mobile E2E verify animation/static geometry, persistence,
|
||||
containment, and touch sizing.
|
||||
|
||||
Final verification passed: 66 focused unit tests, TypeScript typecheck, full
|
||||
workspace lint, `i18n:check`, `i18n:ratchet`, E2E sleep lint, the E2E production
|
||||
build, 8 desktop E2E cases, 7 mobile E2E cases, and `git diff --check`.
|
||||
@@ -187,6 +187,161 @@ Task tools use normal client discovery. When `step_complete_kandev` is required
|
||||
|
||||
`create_task_kandev` advertises `prompt` for instructions delivered to a newly started agent. Older callers may still send `description` when `prompt` is absent, but sending both is an error; the compatibility name is intentionally omitted from the advertised schema.
|
||||
|
||||
### Native rich output
|
||||
|
||||
Task and Office agents can call `show_rich_output_kandev` when a workspace file,
|
||||
trend, comparison, or small metric group is materially clearer as a native
|
||||
presentation than as prose. The injected agent context requires this tool when
|
||||
the user explicitly asks for a chart, graph, plot, or file preview and suitable
|
||||
data is available. Prefer plain text or Markdown for ordinary answers. Small
|
||||
row-and-column comparisons should remain Markdown tables; native tables would
|
||||
duplicate that capability while adding another layout and accessibility
|
||||
contract.
|
||||
|
||||
Version 1 accepts a title, an optional description, and one to four ordered
|
||||
blocks:
|
||||
|
||||
- `file` references a task-workspace-relative path. It can include an optional
|
||||
repository discriminator, title, caption, and MIME type. Kandev never accepts
|
||||
absolute paths, traversal, URLs, data URIs, or inline file bytes.
|
||||
- `chart` renders a line or bar chart and includes a plain-text summary for
|
||||
nonvisual interpretation. Data can be supplied in either of two ways:
|
||||
- Inline `labels` plus 1 to 4 `series`, with one finite numeric or `null`
|
||||
value per label.
|
||||
- A `csv` descriptor with a workspace-relative `.csv` `path`, optional
|
||||
`repo`, exact `x_column`, and 1 to 4 numeric `series` column mappings.
|
||||
Kandev reads 1 to 100 rows in file order. Empty numeric cells become gaps.
|
||||
- `metrics` renders 1 to 6 plain-text label/value pairs with optional details.
|
||||
|
||||
Kandev renders visible x/y values and automatically shortens ISO date/time
|
||||
labels, large numbers, and long categories on the axes. Tooltips retain the
|
||||
original x label and full values. Give every series a clear label and include
|
||||
its unit when useful, for example `p95 (ms)`. A one-series chart displays that
|
||||
label as an informational legend; a multi-series chart renders local keyboard-
|
||||
and touch-operable legend filters. Those filters change only the current view,
|
||||
not the persisted result or agent context.
|
||||
|
||||
The complete tool input is limited to 64 KiB. Titles are limited to 120
|
||||
characters and the optional presentation description to 500 characters.
|
||||
Unknown fields and unsupported versions are rejected. Agents cannot choose
|
||||
HTML, Markdown, JavaScript, CSS, colors, animation, component names, or layout;
|
||||
Kandev owns those details so results remain consistent, accessible, and
|
||||
responsive.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Build health",
|
||||
"description": "Latest local verification",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "metrics",
|
||||
"items": [
|
||||
{ "label": "Passed", "value": "38" },
|
||||
{ "label": "Duration", "value": "12.4s", "detail": "Warm cache" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "Runtime by run",
|
||||
"summary": "Runtime fell across the last five runs.",
|
||||
"labels": ["1", "2", "3", "4", "5"],
|
||||
"series": [
|
||||
{ "label": "Runtime (s)", "values": [18.2, 16.4, 15.1, 13.8, 12.4] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"path": "reports/build.json",
|
||||
"title": "Raw report",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use a CSV-backed line chart for a time series already present in the workspace:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "API latency",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "p50 and p95 latency",
|
||||
"summary": "Tail latency fell across the recorded samples.",
|
||||
"csv": {
|
||||
"path": "reports/latency.csv",
|
||||
"x_column": "recorded_at",
|
||||
"series": [
|
||||
{ "column": "p50_ms", "label": "p50 (ms)" },
|
||||
{ "column": "p95_ms", "label": "p95 (ms)" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The source file might contain:
|
||||
|
||||
```csv
|
||||
recorded_at,p50_ms,p95_ms
|
||||
2026-08-13T10:00:00Z,18.2,29.4
|
||||
2026-08-14T10:00:00Z,16.4,25.1
|
||||
```
|
||||
|
||||
Use the same CSV form for a categorical bar comparison:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Traffic mix",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "chart",
|
||||
"chart_type": "bar",
|
||||
"title": "Requests by route",
|
||||
"summary": "The API route carries most request volume.",
|
||||
"csv": {
|
||||
"path": "reports/routes.csv",
|
||||
"x_column": "route",
|
||||
"series": [{ "column": "requests", "label": "Requests" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
CSV files must be UTF-8 text with one unique header row, no more than 256 KiB,
|
||||
and 1 to 100 data rows. Named columns must exist exactly. X values must be
|
||||
non-empty and at most 120 characters. Selected numeric cells must be finite
|
||||
numbers or empty; malformed rows and invalid values reject the tool call with
|
||||
the source row and column when available.
|
||||
|
||||
A completed presentation persists with the conversation and replays after a
|
||||
reload. For a CSV chart, Kandev resolves selected columns during the tool call
|
||||
and persists only bounded labels plus numeric or `null` values. It never stores
|
||||
the raw CSV and never re-reads it during replay, so later edits or workspace
|
||||
cleanup do not change the accepted chart.
|
||||
|
||||
File-preview bytes do not persist: Kandev fetches a referenced file only after
|
||||
the user selects **Preview**, and **Open file** uses the existing desktop or
|
||||
mobile file viewer. If the task workspace later disappears, the presentation
|
||||
remains while that file block reports that its preview is unavailable.
|
||||
|
||||
This is a Kandev-native MCP tool, not a portable MCP rich-content standard or an
|
||||
MCP App. Portable MCP content uses protocol-level content and resource types that
|
||||
multiple compatible hosts can interpret, usually with less host-specific visual
|
||||
control. MCP Apps let a server supply an interactive `ui://` application that a
|
||||
supporting host runs in a sandbox; that enables richer interaction but introduces
|
||||
a larger trust, permissions, lifecycle, and compatibility surface. Neither path
|
||||
is implemented by this tool, and other MCP clients should not be expected to
|
||||
render its payload as Kandev does.
|
||||
|
||||
### Task dependencies over MCP
|
||||
|
||||
`create_task_kandev` accepts `blocked_by`, a list of task IDs the new task must
|
||||
@@ -286,12 +441,12 @@ Every create response from both the MCP tool result and the REST response body a
|
||||
<details>
|
||||
<summary>Reading the four outcomes</summary>
|
||||
|
||||
| `deduplicated` | `creation_complete` | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `false` | `true` | This call created the task. |
|
||||
| `true` | `true` | An earlier, finished create already holds this identity; that task is returned unchanged, with no new task, agent, or side effect of any kind. |
|
||||
| `true` | `false` | An earlier create for this identity had not finished when observed and may still be running, or may have crashed. The task is returned as-is; nothing about it is started, modified, or assumed finished. |
|
||||
| `false` | `true`, and `external_id` absent from the response | This call finished creating a task, but another actor released or reused the identity in the narrow window before settlement. The task exists and is otherwise normal; it simply is not holding that identity anymore. |
|
||||
| `deduplicated` | `creation_complete` | Meaning |
|
||||
| -------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `false` | `true` | This call created the task. |
|
||||
| `true` | `true` | An earlier, finished create already holds this identity; that task is returned unchanged, with no new task, agent, or side effect of any kind. |
|
||||
| `true` | `false` | An earlier create for this identity had not finished when observed and may still be running, or may have crashed. The task is returned as-is; nothing about it is started, modified, or assumed finished. |
|
||||
| `false` | `true`, and `external_id` absent from the response | This call finished creating a task, but another actor released or reused the identity in the narrow window before settlement. The task exists and is otherwise normal; it simply is not holding that identity anymore. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -337,12 +492,12 @@ Task-mode review automation tools follow the providers attached to the task's
|
||||
repositories. Kandev computes their union when the session launches or
|
||||
resumes:
|
||||
|
||||
| Attached providers | Discoverable tools |
|
||||
| ------------------ | ------------------ |
|
||||
| GitHub only | `get_task_pr_automation_kandev`, `update_task_pr_automation_kandev` |
|
||||
| GitLab only | `get_task_mr_automation_kandev`, `update_task_mr_automation_kandev` |
|
||||
| GitHub and GitLab | Both provider-specific pairs |
|
||||
| None or unsupported | Neither pair |
|
||||
| Attached providers | Discoverable tools |
|
||||
| ------------------- | ------------------------------------------------------------------- |
|
||||
| GitHub only | `get_task_pr_automation_kandev`, `update_task_pr_automation_kandev` |
|
||||
| GitLab only | `get_task_mr_automation_kandev`, `update_task_mr_automation_kandev` |
|
||||
| GitHub and GitLab | Both provider-specific pairs |
|
||||
| None or unsupported | Neither pair |
|
||||
|
||||
Adding a repository source successfully to an idle task can update the live
|
||||
session's task MCP tool list after materialization. If live refresh is
|
||||
|
||||
@@ -208,6 +208,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
| [entity-reference-composer](ui/entity-reference-composer.md) | draft |
|
||||
| [agent-launch-prompt-composer](ui/agent-launch-prompt-composer.md) | shipped |
|
||||
| [mermaid-rendering](ui/mermaid-rendering.md) | shipped |
|
||||
| [agent-rich-output](agent-rich-output/spec.md) | shipped |
|
||||
| [message-queue-auto-merge](ui/message-queue-auto-merge.md) | shipped |
|
||||
| [message-queue-management](ui/message-queue-management.md) | shipped |
|
||||
| [message-queue-merge](ui/message-queue-merge.md) | shipped |
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-08-14
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Agent Rich Output
|
||||
|
||||
## Why
|
||||
|
||||
Agents can explain results in Markdown, but trends and workspace files often
|
||||
need a more direct visual treatment. Users need those results to remain calm,
|
||||
readable, and trustworthy inside the transcript instead of becoming arbitrary
|
||||
agent-designed dashboards.
|
||||
|
||||
## What
|
||||
|
||||
- Task and Office agents can call `show_rich_output_kandev` to add one native,
|
||||
replayable presentation to the conversation.
|
||||
- A presentation has a plain-text title, an optional plain-text description,
|
||||
and one to four ordered blocks.
|
||||
- Version 1 supports only file previews, line or bar charts, and metric groups.
|
||||
- Line and bar charts may receive values inline or select columns from a
|
||||
workspace CSV. CSV-backed results persist as normalized snapshots so they do
|
||||
not depend on the later workspace lifecycle.
|
||||
- Every chart displays readable x- and y-axis values. Kandev automatically
|
||||
shortens ISO date/time labels and large numbers for the plot while preserving
|
||||
the original x value in the tooltip.
|
||||
- Every chart identifies its series in a legend. Multi-series legends are
|
||||
local keyboard- and touch-operable filters; changing them never changes the
|
||||
persisted presentation or sends a tool callback.
|
||||
- Line and bar charts retain their native Recharts entrance animation by
|
||||
default. Kandev defers the expensive plot until its chart is near the
|
||||
viewport in a visible browser tab, then mounts and animates it once.
|
||||
- Appearance settings provide a per-device option to disable rich-output chart
|
||||
animation. The operating system's reduced-motion preference also disables
|
||||
it, regardless of the saved option.
|
||||
- Unchanged completed presentations reuse their parsed payload and derived
|
||||
chart inputs across unrelated transcript rerenders. Local legend filtering
|
||||
rerenders only the affected chart, keeps its data/configuration objects
|
||||
stable, and does not restart its entrance animation.
|
||||
- Kandev owns all typography, spacing, color, layout, accessibility, and
|
||||
responsive behavior. Agent input never supplies HTML, Markdown, JavaScript,
|
||||
CSS, remote URLs, colors, animation, or component names.
|
||||
- Completed rich-output calls remain standalone transcript items rather than
|
||||
being collapsed into the generic tool-activity group.
|
||||
- Pending calls use the normal compact tool state. Failed calls show the normal
|
||||
tool error. A completed call with unsupported or malformed data shows a
|
||||
compact localized unavailable state and does not break nearby messages.
|
||||
- Small textual comparisons continue to use Markdown tables. Version 1 does
|
||||
not provide a native data-table block.
|
||||
- Always-injected Task and Office guidance includes one exact inline-chart,
|
||||
CSV-chart, and metric-group recipe. It tells agents to call the native tool
|
||||
directly for explicit presentation requests instead of building ASCII, SVG,
|
||||
HTML, or another app. The MCP description stays a focused routing summary,
|
||||
while its schema retains complete chart examples. Kandev, rather than the
|
||||
agent, owns axes, legends, colors, and layout; agents provide only semantic
|
||||
blocks, inline chart values, or CSV column mappings.
|
||||
|
||||
## API surface
|
||||
|
||||
`show_rich_output_kandev` is present only in the Kanban-task and Office-task
|
||||
MCP profiles. It is absent from configuration and external MCP profiles.
|
||||
|
||||
The tool accepts a closed JSON object. Unknown fields fail validation.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Build health",
|
||||
"description": "Latest local verification",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "metrics",
|
||||
"items": [
|
||||
{ "label": "Passed", "value": "38" },
|
||||
{ "label": "Duration", "value": "12.4s", "detail": "Warm cache" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "chart",
|
||||
"chart_type": "line",
|
||||
"title": "Runtime by run",
|
||||
"summary": "Runtime fell across the last five runs.",
|
||||
"csv": {
|
||||
"path": "reports/build-times.csv",
|
||||
"x_column": "started_at",
|
||||
"series": [
|
||||
{ "column": "seconds", "label": "Seconds" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"path": "reports/build.json",
|
||||
"title": "Raw report",
|
||||
"caption": "Generated by the verification run",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Top-level limits:
|
||||
|
||||
- `version` is exactly `1`.
|
||||
- `title` is 1 to 120 characters.
|
||||
- `description`, when present, is at most 500 characters.
|
||||
- `blocks` contains 1 to 4 items.
|
||||
- The complete encoded argument object is at most 64 KiB.
|
||||
|
||||
Block contracts:
|
||||
|
||||
- `file`: `path` is a non-empty workspace-relative path of at most 1024
|
||||
characters. Optional `repo`, `title`, `caption`, and `mime_type` are plain
|
||||
strings. Absolute paths, traversal segments, URLs, data URIs, and inline
|
||||
file bytes are rejected.
|
||||
- `chart`: `chart_type` is `line` or `bar`; `title` and `summary` are required.
|
||||
Its data is exactly one of:
|
||||
- inline `labels` with 1 to 100 plain strings and `series` with 1 to 4
|
||||
entries. Every series contains the same number of finite numeric or `null`
|
||||
values as `labels`;
|
||||
- `csv` with a workspace-relative `.csv` `path`, optional `repo`, an exact
|
||||
`x_column`, and 1 to 4 series mappings. Each mapping has an exact numeric
|
||||
`column` and optional display `label`.
|
||||
Kandev assigns visual colors, dimensions, axis formatting, tooltip behavior,
|
||||
and legend controls in both forms. A one-series chart still names that series
|
||||
in a non-interactive legend; a multi-series chart lets the user show or hide
|
||||
each series locally.
|
||||
- `metrics`: `items` has 1 to 6 entries. Every item has a plain-text `label`
|
||||
and display `value`, plus optional plain-text `detail`. Metrics carry no
|
||||
agent-selected semantic color.
|
||||
|
||||
Inline-only calls return a short text acknowledgement. Their canonical
|
||||
presentation payload is the tool input already stored at
|
||||
`metadata.normalized.generic.input`. Calls containing CSV charts return a
|
||||
versioned `resolved_charts` result, as both standard MCP structured content and
|
||||
an equivalent JSON text fallback. Each entry identifies its chart block index
|
||||
and stores only bounded labels plus numeric or `null` series values.
|
||||
|
||||
Decision: [ADR-2026-08-14-kandev-native-agent-rich-output](../../decisions/2026-08-14-kandev-native-agent-rich-output.md).
|
||||
|
||||
## Permissions
|
||||
|
||||
The tool inherits the current task session's existing MCP identity and message
|
||||
authorization. It does not accept a task or session identifier and performs no
|
||||
separate backend mutation. File expansion uses the current session's existing
|
||||
workspace-file authorization.
|
||||
|
||||
The tool is advertised as a read-only presentation tool. Test and demo agent
|
||||
profiles may still choose a stricter provider permission policy, so isolated
|
||||
real-agent evaluation profiles must explicitly enable full access and automatic
|
||||
approval when the evaluation is intended to run unattended.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Schema or semantic validation failure rejects the tool call with a concise
|
||||
error and produces no rich presentation.
|
||||
- CSV resolution rejects missing or binary files, files over 256 KiB, malformed
|
||||
rows, duplicate or missing headers, zero or more than 100 data rows, blank or
|
||||
overlong x values, and non-finite numeric cells. Errors identify the source
|
||||
row and column when possible. Empty numeric cells become chart gaps.
|
||||
- A pending tool call does not render unvalidated agent data as a presentation.
|
||||
- A malformed historic completed payload fails closed in the frontend and
|
||||
leaves the rest of the transcript usable.
|
||||
- File content is never fetched automatically. Expansion errors, missing
|
||||
files, binary formats without a preview, and expired task workspaces render
|
||||
a localized unavailable state while other blocks remain usable.
|
||||
- Unknown future versions are not guessed. They render the unavailable state.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
The presentation payload follows existing tool-call message retention and
|
||||
survives WebSocket replay, page reload, and backend restart without a new
|
||||
database entity. Inline blocks replay from arguments. CSV-backed charts replay
|
||||
from their normalized result snapshot and never re-read the source. Referenced
|
||||
file-preview bytes remain owned by the task workspace and can disappear when
|
||||
that workspace is archived, deleted, reset, or cleaned; the message remains and
|
||||
its file block degrades explicitly.
|
||||
|
||||
Codex ACP may transport MCP calls in an `execute` tool frame whose `rawInput`
|
||||
contains the MCP server, actual tool name, and `arguments`. The ACP adapter
|
||||
recognizes that provider envelope and persists a provider-neutral generic tool
|
||||
call using the actual MCP tool name, arguments, and completed MCP result. The
|
||||
shell-shaped transport category is never exposed as the presentation's stored
|
||||
tool identity.
|
||||
|
||||
No raw CSV, file bytes, images, or other binary payloads are copied into SQLite
|
||||
for this feature.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- Desktop and mobile entry point: the completed presentation appears inline at
|
||||
its chronological location in the existing task or Office transcript.
|
||||
- Nearest mobile exemplars: the existing chat transcript supplies the inline
|
||||
surface; `MobileFileViewerPanel` supplies focused file inspection.
|
||||
- Hierarchy: presentation title, optional description, then ordered blocks.
|
||||
File open and chart inspection are the primary block actions.
|
||||
- Presentation remains inline because it is conversation evidence, not a new
|
||||
primary destination. Opening a file uses the existing dedicated mobile file
|
||||
surface rather than embedding a desktop editor in chat.
|
||||
- The transcript remains the single vertical scroll owner. Charts stay within
|
||||
their block width, metric items reflow without horizontal page scrolling,
|
||||
legend controls wrap within the chart, and actions have touch-safe targets.
|
||||
- Desktop and mobile share parsing, validation, data, and actions. Only block
|
||||
composition and sizing vary with `useResponsiveBreakpoint` where needed.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a task or Office agent, **WHEN** it lists MCP tools, **THEN**
|
||||
`show_rich_output_kandev` is available with the closed version 1 schema.
|
||||
- **GIVEN** an agent is asked for an inline line or bar chart, **WHEN** it reads
|
||||
Kandev's tool guidance, **THEN** it can copy the canonical `chart_type`,
|
||||
`title`, `summary`, `labels`, and labeled `series[].values` shape without
|
||||
inventing axis, category, or data fields.
|
||||
- **GIVEN** an agent is explicitly asked for a chart, file preview, KPI cards,
|
||||
or a native metric summary and suitable data exists, **WHEN** it chooses a
|
||||
response mechanism, **THEN** it calls `show_rich_output_kandev` directly
|
||||
instead of creating an ASCII, SVG, HTML, or app-based substitute.
|
||||
- **GIVEN** an agent is asked for KPI cards or a native metric summary, **WHEN**
|
||||
it reads Kandev's tool guidance, **THEN** it can copy the canonical metric
|
||||
`items[].label` and `items[].value` shape without editing a workspace file.
|
||||
- **GIVEN** a configuration or external MCP client, **WHEN** it lists tools,
|
||||
**THEN** `show_rich_output_kandev` is absent.
|
||||
- **GIVEN** a valid completed presentation, **WHEN** the transcript receives
|
||||
the tool update, **THEN** its file, chart, and metric blocks render as one
|
||||
standalone native transcript item.
|
||||
- **GIVEN** the same completed message after a page reload, **WHEN** history is
|
||||
replayed, **THEN** the presentation is equivalent to its live rendering.
|
||||
- **GIVEN** Codex ACP emits an MCP invocation as an `execute` frame with
|
||||
`_meta.is_mcp_tool_call`, **WHEN** the initial and terminal updates are
|
||||
normalized, **THEN** the transcript stores one generic
|
||||
`show_rich_output_kandev` tool call with unwrapped arguments and MCP result.
|
||||
- **GIVEN** a pending rich-output call, **WHEN** its arguments arrive, **THEN**
|
||||
the transcript shows only the normal pending tool state.
|
||||
- **GIVEN** malformed input, an unknown block, or a payload over 64 KiB,
|
||||
**WHEN** the tool is called, **THEN** validation fails closed.
|
||||
- **GIVEN** a workspace CSV with an x column and numeric columns, **WHEN** a
|
||||
line or bar chart names those columns, **THEN** the handler resolves at most
|
||||
100 rows and persists a normalized result snapshot.
|
||||
- **GIVEN** a completed CSV-backed chart whose workspace file is later edited
|
||||
or removed, **WHEN** conversation history reloads, **THEN** the chart remains
|
||||
identical to the accepted snapshot and performs no workspace read.
|
||||
- **GIVEN** a valid line or bar chart, **WHEN** it renders, **THEN** both axes
|
||||
contain visible tick values, its tooltip exposes the original x value, and
|
||||
every series is named in the legend.
|
||||
- **GIVEN** a multi-series chart, **WHEN** the user toggles a legend item with a
|
||||
pointer, touch, or keyboard, **THEN** that series is filtered locally and can
|
||||
be restored without changing the persisted payload.
|
||||
- **GIVEN** a valid line or bar chart below the viewport or in a background
|
||||
browser tab, **WHEN** its transcript loads, **THEN** Kandev preserves the
|
||||
chart's title, summary, and layout space without mounting or animating the
|
||||
expensive plot.
|
||||
- **GIVEN** that deferred chart, **WHEN** it approaches the viewport in a
|
||||
visible tab, **THEN** its plot mounts once and retains the default Recharts
|
||||
entrance animation.
|
||||
- **GIVEN** an unchanged completed rich-output message, **WHEN** unrelated chat
|
||||
state rerenders its parent, **THEN** Kandev reuses the parsed presentation,
|
||||
chart data, chart configuration, and formatter callbacks instead of
|
||||
rebuilding the chart subtree or restarting its entrance animation.
|
||||
- **GIVEN** the per-device rich-output animation option is disabled, or the
|
||||
operating system requests reduced motion, **WHEN** a chart plot mounts,
|
||||
**THEN** its complete series geometry appears without animation while axes,
|
||||
tooltips, and legend filtering remain available.
|
||||
- **GIVEN** a valid presentation with a workspace file, **WHEN** the user
|
||||
expands the file block, **THEN** Kandev requests the file once and displays
|
||||
a bounded preview or explicit unavailable state.
|
||||
- **GIVEN** a file block in a multi-repository task, **WHEN** the user opens
|
||||
it, **THEN** the optional repository discriminator reaches the existing file
|
||||
viewer selection path.
|
||||
- **GIVEN** a Pixel 5-sized viewport, **WHEN** file, chart, and metric blocks
|
||||
render, **THEN** content stays inside the viewport, actions remain touchable,
|
||||
and file opening reaches the existing mobile viewer.
|
||||
- **GIVEN** a small row-and-column comparison, **WHEN** an agent reports it,
|
||||
**THEN** it uses ordinary Markdown rather than a native rich table block.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Portable rendering of every MCP or ACP content block.
|
||||
- MCP Apps, `ui://` resources, iframes, executable UI, or tool callbacks.
|
||||
- Plugin-contributed rich renderers or changes to the plugin agent-tool result
|
||||
contract.
|
||||
- Native tables, forms, editors, maps, arbitrary dashboards, or agent-defined
|
||||
layout.
|
||||
- Changing Recharts' default entrance style or duration for users who leave
|
||||
rich-output animation enabled.
|
||||
- Transcript-wide virtualization, a canvas chart renderer, data sampling, or
|
||||
lower chart payload limits. Those require separate evidence after deferred
|
||||
mounting and referential-stability work is measured.
|
||||
- Durable raw artifact storage or a new workspace file-preview API.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Original implementation plan](../../plans/agent-rich-output/plan.md)
|
||||
- [Chart rendering performance repair](../../plans/rich-output-chart-performance/plan.md)
|
||||
Reference in New Issue
Block a user