Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add persistent console plan and activity panels

Lyric c583f9f9 bb603f99

+1789 -71
+2 -2
agent/engine_execute_tool_test.go
··· 30 30 }) 31 31 32 32 engine := New(nil, reg, Config{}, DefaultPromptSpec()) 33 - observation, err := engine.executeTool(context.Background(), &engineLoopState{}, &ToolCall{Name: "structured"}) 33 + observation, err := engine.executeTool(context.Background(), &engineLoopState{}, 0, &ToolCall{Name: "structured"}) 34 34 if err == nil { 35 35 t.Fatal("expected error, got nil") 36 36 } ··· 48 48 }) 49 49 50 50 engine := New(nil, reg, Config{}, DefaultPromptSpec()) 51 - observation, err := engine.executeTool(context.Background(), &engineLoopState{}, &ToolCall{Name: "plain"}) 51 + observation, err := engine.executeTool(context.Background(), &engineLoopState{}, 0, &ToolCall{Name: "plain"}) 52 52 if err == nil { 53 53 t.Fatal("expected error, got nil") 54 54 }
+22
agent/engine_helpers.go
··· 176 176 } 177 177 return out 178 178 } 179 + 180 + func toolDisplayArgsSummary(toolName string, params map[string]any, opts LogOptions) map[string]any { 181 + if len(params) == 0 { 182 + return nil 183 + } 184 + 185 + opts = normalizeLogOptions(opts) 186 + opts.IncludeToolParams = true 187 + if out := toolArgsSummary(toolName, params, opts, false); len(out) > 0 { 188 + return out 189 + } 190 + 191 + maxStr := opts.MaxStringValueChars 192 + if maxStr <= 0 || maxStr > 240 { 193 + maxStr = 240 194 + } 195 + sanitized, _ := sanitizeValue(params, maxStr, opts.RedactKeys, "").(map[string]any) 196 + if len(sanitized) == 0 { 197 + return nil 198 + } 199 + return sanitized 200 + }
+30
agent/engine_helpers_test.go
··· 73 73 t.Fatalf("api_key should be redacted, got %#v", body["api_key"]) 74 74 } 75 75 } 76 + 77 + func TestToolDisplayArgsSummary_BashIncludesCommand(t *testing.T) { 78 + opts := DefaultLogOptions() 79 + got := toolDisplayArgsSummary("bash", map[string]any{ 80 + "cmd": "printf 'ok'\n", 81 + }, opts) 82 + if got == nil { 83 + t.Fatal("summary = nil, want command") 84 + } 85 + if got["cmd"] != "printf 'ok'" { 86 + t.Fatalf("cmd = %#v, want %#v", got["cmd"], "printf 'ok'") 87 + } 88 + } 89 + 90 + func TestToolDisplayArgsSummary_FallbackSanitizesUnknownTool(t *testing.T) { 91 + opts := DefaultLogOptions() 92 + got := toolDisplayArgsSummary("custom_tool", map[string]any{ 93 + "api_key": "secret", 94 + "query": "alpha", 95 + }, opts) 96 + if got == nil { 97 + t.Fatal("summary = nil, want sanitized params") 98 + } 99 + if got["api_key"] != "[redacted]" { 100 + t.Fatalf("api_key = %#v, want redacted", got["api_key"]) 101 + } 102 + if got["query"] != "alpha" { 103 + t.Fatalf("query = %#v, want alpha", got["query"]) 104 + } 105 + }
+31 -10
agent/engine_loop.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "hash/fnv" 7 8 "log/slog" 8 9 "math/rand/v2" 9 10 "strings" ··· 405 406 } 406 407 item := &items[i] 407 408 g.Go(func() error { 408 - obs, toolErr := e.executeTool(gCtx, st, &item.tc) 409 + obs, toolErr := e.executeTool(gCtx, st, step, &item.tc) 409 410 item.observation = obs 410 411 item.err = toolErr 411 412 item.executed = true ··· 523 524 ) 524 525 } 525 526 EmitEvent(ctx, nil, Event{ 526 - Kind: EventKindToolDone, 527 - Step: step, 528 - ToolName: strings.TrimSpace(tc.Name), 529 - Status: toolEventStatus(item.err), 530 - Error: eventErrorString(item.err), 527 + Kind: EventKindToolDone, 528 + Step: step, 529 + ActivityID: toolActivityID(step, &tc), 530 + ToolName: strings.TrimSpace(tc.Name), 531 + Status: toolEventStatus(item.err), 532 + Error: eventErrorString(item.err), 533 + Args: toolDisplayArgsSummary(strings.TrimSpace(tc.Name), tc.Params, e.logOpts), 531 534 }) 532 535 533 536 if item.err == nil { ··· 641 644 } 642 645 643 646 // executeTool runs the tool. Safe for concurrent use. 644 - func (e *Engine) executeTool(ctx context.Context, st *engineLoopState, tc *ToolCall) (string, error) { 647 + func (e *Engine) executeTool(ctx context.Context, st *engineLoopState, step int, tc *ToolCall) (string, error) { 645 648 tool, found := e.registry.Get(tc.Name) 646 649 if !found { 647 650 return fmt.Sprintf("Error: tool '%s' not found. Available tools: %s", tc.Name, e.registry.ToolNames()), fmt.Errorf("tool not found") ··· 649 652 650 653 toolCtx := ctx 651 654 EmitEvent(ctx, nil, Event{ 652 - Kind: EventKindToolStart, 653 - ToolName: strings.TrimSpace(tc.Name), 654 - Status: "running", 655 + Kind: EventKindToolStart, 656 + ActivityID: toolActivityID(step, tc), 657 + ToolName: strings.TrimSpace(tc.Name), 658 + Status: "running", 659 + Args: toolDisplayArgsSummary(strings.TrimSpace(tc.Name), tc.Params, e.logOpts), 655 660 }) 656 661 if e.subtaskRunner != nil { 657 662 toolCtx = WithSubtaskRunnerContext(toolCtx, e.subtaskRunner) ··· 710 715 } 711 716 b, _ := json.Marshal(tc.Params) 712 717 return tc.Name + ":" + string(b) 718 + } 719 + 720 + func toolActivityID(step int, tc *ToolCall) string { 721 + if tc == nil { 722 + return "" 723 + } 724 + if id := strings.TrimSpace(tc.ID); id != "" { 725 + return id 726 + } 727 + sig := toolCallSignature(*tc) 728 + if sig == "" { 729 + return fmt.Sprintf("tool:%d:%s", step, normalizedToolName(tc.Name)) 730 + } 731 + hasher := fnv.New64a() 732 + _, _ = hasher.Write([]byte(sig)) 733 + return fmt.Sprintf("tool:%d:%016x", step, hasher.Sum64()) 713 734 } 714 735 715 736 func normalizedToolName(name string) string {
+15 -13
agent/events.go
··· 16 16 ) 17 17 18 18 type Event struct { 19 - Kind string `json:"kind"` 20 - RunID string `json:"run_id,omitempty"` 21 - Step int `json:"step,omitempty"` 22 - ToolName string `json:"tool_name,omitempty"` 23 - TaskID string `json:"task_id,omitempty"` 24 - Status string `json:"status,omitempty"` 25 - Mode string `json:"mode,omitempty"` 26 - Profile string `json:"profile,omitempty"` 27 - Stream string `json:"stream,omitempty"` 28 - Text string `json:"text,omitempty"` 29 - Summary string `json:"summary,omitempty"` 30 - OutputKind string `json:"output_kind,omitempty"` 31 - Error string `json:"error,omitempty"` 19 + Kind string `json:"kind"` 20 + RunID string `json:"run_id,omitempty"` 21 + Step int `json:"step,omitempty"` 22 + ActivityID string `json:"activity_id,omitempty"` 23 + ToolName string `json:"tool_name,omitempty"` 24 + TaskID string `json:"task_id,omitempty"` 25 + Status string `json:"status,omitempty"` 26 + Mode string `json:"mode,omitempty"` 27 + Profile string `json:"profile,omitempty"` 28 + Stream string `json:"stream,omitempty"` 29 + Text string `json:"text,omitempty"` 30 + Summary string `json:"summary,omitempty"` 31 + OutputKind string `json:"output_kind,omitempty"` 32 + Error string `json:"error,omitempty"` 33 + Args map[string]any `json:"args,omitempty"` 32 34 } 33 35 34 36 type EventSink interface {
+7 -5
agent/local_subtask_runner.go
··· 23 23 taskID, runCtx, meta := PrepareSubtaskContext(ctx, req.Meta) 24 24 log := r.engine.log 25 25 EmitEvent(ctx, nil, Event{ 26 - Kind: EventKindSubtaskStart, 27 - TaskID: taskID, 28 - Mode: localSubtaskMode(req), 29 - Profile: string(NormalizeObserveProfile(string(req.ObserveProfile))), 30 - Status: "running", 26 + Kind: EventKindSubtaskStart, 27 + ActivityID: taskID, 28 + TaskID: taskID, 29 + Mode: localSubtaskMode(req), 30 + Profile: string(NormalizeObserveProfile(string(req.ObserveProfile))), 31 + Status: "running", 31 32 }) 32 33 if log != nil { 33 34 log.Info("subtask_start", "task_id", taskID, "mode", localSubtaskMode(req), "output_schema", strings.TrimSpace(req.OutputSchema)) ··· 51 52 if result != nil { 52 53 EmitEvent(ctx, nil, Event{ 53 54 Kind: EventKindSubtaskDone, 55 + ActivityID: taskID, 54 56 TaskID: taskID, 55 57 Status: strings.TrimSpace(result.Status), 56 58 Summary: strings.TrimSpace(result.Summary),
+181
cmd/mistermorph/consolecmd/activity_progress.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/quailyquaily/mistermorph/agent" 7 + ) 8 + 9 + const consoleActivityHistoryLimit = 24 10 + 11 + type consoleActivityProgress struct { 12 + Current *consoleActivityEntry `json:"current,omitempty"` 13 + History []consoleActivityEntry `json:"history,omitempty"` 14 + } 15 + 16 + type consoleActivityEntry struct { 17 + ID string `json:"id"` 18 + Kind string `json:"kind"` 19 + Name string `json:"name,omitempty"` 20 + Status string `json:"status,omitempty"` 21 + Args map[string]any `json:"args,omitempty"` 22 + Summary string `json:"summary,omitempty"` 23 + Error string `json:"error,omitempty"` 24 + TaskID string `json:"task_id,omitempty"` 25 + Mode string `json:"mode,omitempty"` 26 + Profile string `json:"profile,omitempty"` 27 + OutputKind string `json:"output_kind,omitempty"` 28 + } 29 + 30 + func cloneConsoleActivityProgress(progress *consoleActivityProgress) *consoleActivityProgress { 31 + if progress == nil { 32 + return nil 33 + } 34 + out := &consoleActivityProgress{ 35 + Current: cloneConsoleActivityEntry(progress.Current), 36 + History: make([]consoleActivityEntry, 0, len(progress.History)), 37 + } 38 + for _, entry := range progress.History { 39 + cloned := cloneConsoleActivityEntry(&entry) 40 + if cloned == nil { 41 + continue 42 + } 43 + out.History = append(out.History, *cloned) 44 + } 45 + if out.Current == nil && len(out.History) > 0 { 46 + last := out.History[len(out.History)-1] 47 + out.Current = cloneConsoleActivityEntry(&last) 48 + } 49 + if out.Current == nil && len(out.History) == 0 { 50 + return nil 51 + } 52 + return out 53 + } 54 + 55 + func cloneConsoleActivityEntry(entry *consoleActivityEntry) *consoleActivityEntry { 56 + if entry == nil { 57 + return nil 58 + } 59 + out := *entry 60 + if len(entry.Args) > 0 { 61 + out.Args = cloneConsoleArgs(entry.Args) 62 + } 63 + return &out 64 + } 65 + 66 + func cloneConsoleArgs(args map[string]any) map[string]any { 67 + if len(args) == 0 { 68 + return nil 69 + } 70 + out := make(map[string]any, len(args)) 71 + for key, value := range args { 72 + out[key] = value 73 + } 74 + return out 75 + } 76 + 77 + func updateConsoleActivityProgress(progress *consoleActivityProgress, event agent.Event) (*consoleActivityProgress, bool) { 78 + entry := buildConsoleActivityEntry(event) 79 + if entry == nil { 80 + return cloneConsoleActivityProgress(progress), false 81 + } 82 + if progress == nil { 83 + progress = &consoleActivityProgress{} 84 + } 85 + 86 + index := -1 87 + for i := range progress.History { 88 + if progress.History[i].ID == entry.ID { 89 + index = i 90 + break 91 + } 92 + } 93 + if index >= 0 { 94 + progress.History[index] = mergeConsoleActivityEntry(progress.History[index], *entry) 95 + progress.Current = cloneConsoleActivityEntry(&progress.History[index]) 96 + return cloneConsoleActivityProgress(progress), true 97 + } 98 + 99 + progress.History = append(progress.History, *entry) 100 + if len(progress.History) > consoleActivityHistoryLimit { 101 + progress.History = append([]consoleActivityEntry(nil), progress.History[len(progress.History)-consoleActivityHistoryLimit:]...) 102 + } 103 + last := progress.History[len(progress.History)-1] 104 + progress.Current = cloneConsoleActivityEntry(&last) 105 + return cloneConsoleActivityProgress(progress), true 106 + } 107 + 108 + func buildConsoleActivityEntry(event agent.Event) *consoleActivityEntry { 109 + id := strings.TrimSpace(event.ActivityID) 110 + if id == "" { 111 + id = strings.TrimSpace(event.TaskID) 112 + } 113 + if id == "" { 114 + return nil 115 + } 116 + 117 + entry := &consoleActivityEntry{ 118 + ID: id, 119 + Status: strings.TrimSpace(event.Status), 120 + Summary: strings.TrimSpace(event.Summary), 121 + Error: strings.TrimSpace(event.Error), 122 + TaskID: strings.TrimSpace(event.TaskID), 123 + Mode: strings.TrimSpace(event.Mode), 124 + Profile: strings.TrimSpace(event.Profile), 125 + OutputKind: strings.TrimSpace(event.OutputKind), 126 + Args: cloneConsoleArgs(event.Args), 127 + } 128 + 129 + switch strings.TrimSpace(event.Kind) { 130 + case agent.EventKindToolStart, agent.EventKindToolDone: 131 + entry.Kind = "tool" 132 + entry.Name = strings.TrimSpace(event.ToolName) 133 + case agent.EventKindSubtaskStart, agent.EventKindSubtaskDone: 134 + entry.Kind = "subtask" 135 + entry.Name = strings.TrimSpace(event.TaskID) 136 + default: 137 + return nil 138 + } 139 + 140 + if entry.Kind == "tool" && entry.Name == "" { 141 + entry.Name = "tool" 142 + } 143 + if entry.Kind == "subtask" && entry.Name == "" { 144 + entry.Name = "subtask" 145 + } 146 + return entry 147 + } 148 + 149 + func mergeConsoleActivityEntry(base consoleActivityEntry, update consoleActivityEntry) consoleActivityEntry { 150 + if strings.TrimSpace(update.Kind) != "" { 151 + base.Kind = strings.TrimSpace(update.Kind) 152 + } 153 + if strings.TrimSpace(update.Name) != "" { 154 + base.Name = strings.TrimSpace(update.Name) 155 + } 156 + if strings.TrimSpace(update.Status) != "" { 157 + base.Status = strings.TrimSpace(update.Status) 158 + } 159 + if len(update.Args) > 0 { 160 + base.Args = cloneConsoleArgs(update.Args) 161 + } 162 + if strings.TrimSpace(update.Summary) != "" { 163 + base.Summary = strings.TrimSpace(update.Summary) 164 + } 165 + if strings.TrimSpace(update.Error) != "" { 166 + base.Error = strings.TrimSpace(update.Error) 167 + } 168 + if strings.TrimSpace(update.TaskID) != "" { 169 + base.TaskID = strings.TrimSpace(update.TaskID) 170 + } 171 + if strings.TrimSpace(update.Mode) != "" { 172 + base.Mode = strings.TrimSpace(update.Mode) 173 + } 174 + if strings.TrimSpace(update.Profile) != "" { 175 + base.Profile = strings.TrimSpace(update.Profile) 176 + } 177 + if strings.TrimSpace(update.OutputKind) != "" { 178 + base.OutputKind = strings.TrimSpace(update.OutputKind) 179 + } 180 + return base 181 + }
+82
cmd/mistermorph/consolecmd/activity_progress_test.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/quailyquaily/mistermorph/agent" 7 + ) 8 + 9 + func TestUpdateConsoleActivityProgressMergesToolLifecycle(t *testing.T) { 10 + var progress *consoleActivityProgress 11 + 12 + progress, changed := updateConsoleActivityProgress(progress, agent.Event{ 13 + Kind: agent.EventKindToolStart, 14 + ActivityID: "tool:1", 15 + ToolName: "web_search", 16 + Status: "running", 17 + Args: map[string]any{ 18 + "q": "mistermorph", 19 + }, 20 + }) 21 + if !changed || progress == nil { 22 + t.Fatalf("first update changed=%v progress=%#v, want non-nil progress", changed, progress) 23 + } 24 + 25 + progress, changed = updateConsoleActivityProgress(progress, agent.Event{ 26 + Kind: agent.EventKindToolDone, 27 + ActivityID: "tool:1", 28 + ToolName: "web_search", 29 + Status: "done", 30 + }) 31 + if !changed { 32 + t.Fatal("tool done should update activity progress") 33 + } 34 + if progress.Current == nil { 35 + t.Fatal("progress.Current = nil") 36 + } 37 + if progress.Current.Status != "done" { 38 + t.Fatalf("progress.Current.Status = %q, want done", progress.Current.Status) 39 + } 40 + if progress.Current.Args["q"] != "mistermorph" { 41 + t.Fatalf("progress.Current.Args[q] = %#v, want mistermorph", progress.Current.Args["q"]) 42 + } 43 + if len(progress.History) != 1 { 44 + t.Fatalf("len(progress.History) = %d, want 1", len(progress.History)) 45 + } 46 + } 47 + 48 + func TestUpdateConsoleActivityProgressTracksSubtaskHistory(t *testing.T) { 49 + var progress *consoleActivityProgress 50 + 51 + progress, _ = updateConsoleActivityProgress(progress, agent.Event{ 52 + Kind: agent.EventKindSubtaskStart, 53 + ActivityID: "sub_1", 54 + TaskID: "sub_1", 55 + Mode: "agent", 56 + Profile: string(agent.ObserveProfileWebExtract), 57 + Status: "running", 58 + }) 59 + progress, _ = updateConsoleActivityProgress(progress, agent.Event{ 60 + Kind: agent.EventKindSubtaskDone, 61 + ActivityID: "sub_1", 62 + TaskID: "sub_1", 63 + Status: agent.SubtaskStatusDone, 64 + Summary: "collected results", 65 + OutputKind: agent.SubtaskOutputKindJSON, 66 + }) 67 + if progress == nil || progress.Current == nil { 68 + t.Fatalf("progress = %#v, want current entry", progress) 69 + } 70 + if progress.Current.Kind != "subtask" { 71 + t.Fatalf("progress.Current.Kind = %q, want subtask", progress.Current.Kind) 72 + } 73 + if progress.Current.Name != "sub_1" { 74 + t.Fatalf("progress.Current.Name = %q, want sub_1", progress.Current.Name) 75 + } 76 + if progress.Current.Summary != "collected results" { 77 + t.Fatalf("progress.Current.Summary = %q, want collected results", progress.Current.Summary) 78 + } 79 + if progress.Current.OutputKind != agent.SubtaskOutputKindJSON { 80 + t.Fatalf("progress.Current.OutputKind = %q, want %q", progress.Current.OutputKind, agent.SubtaskOutputKindJSON) 81 + } 82 + }
+60 -5
cmd/mistermorph/consolecmd/local_runtime.go
··· 1116 1116 } 1117 1117 1118 1118 replySink := newConsoleReplySink(r.streamHub, job.TaskID, logger) 1119 + var progressMu sync.Mutex 1120 + var latestPlan *consolePlanProgress 1121 + var latestActivity *consoleActivityProgress 1122 + storeProgress := func() { 1123 + progressMu.Lock() 1124 + plan := cloneConsolePlanProgress(latestPlan) 1125 + activity := cloneConsoleActivityProgress(latestActivity) 1126 + progressMu.Unlock() 1127 + if plan == nil && activity == nil { 1128 + return 1129 + } 1130 + result := map[string]any{} 1131 + if plan != nil { 1132 + result["plan"] = plan 1133 + } 1134 + if activity != nil { 1135 + result["activity"] = activity 1136 + } 1137 + r.store.Update(job.TaskID, func(info *daemonruntime.TaskInfo) { 1138 + info.Result = result 1139 + }) 1140 + } 1119 1141 eventSink := newConsoleEventPreviewSink(r.streamHub, job.TaskID, logger) 1142 + eventSink.activityUpdated = func(progress *consoleActivityProgress) { 1143 + progressMu.Lock() 1144 + latestActivity = cloneConsoleActivityProgress(progress) 1145 + progressMu.Unlock() 1146 + storeProgress() 1147 + } 1120 1148 if bundle := job.Generation.bundle; bundle != nil { 1121 1149 eventSink.observer = newConsoleLLMObserver(bundle.taskRuntime, job.Model, logger) 1122 1150 } ··· 1130 1158 1131 1159 runCtx, cancel := context.WithTimeout(workerCtx, job.Timeout) 1132 1160 runCtx = agent.WithEventSinkContext(runCtx, eventSink) 1133 - final, agentCtx, runErr := r.runTask(runCtx, conversationKey, job, onStream) 1161 + planStepUpdate := func(runCtx *agent.Context, _ agent.PlanStepUpdate) { 1162 + progress := buildConsolePlanProgress(consoleTaskPlan(nil, runCtx)) 1163 + if progress == nil { 1164 + return 1165 + } 1166 + progressMu.Lock() 1167 + latestPlan = cloneConsolePlanProgress(progress) 1168 + progressMu.Unlock() 1169 + storeProgress() 1170 + if r.streamHub != nil { 1171 + r.streamHub.PublishPlan(job.TaskID, progress) 1172 + } 1173 + } 1174 + 1175 + final, agentCtx, runErr := r.runTask(runCtx, conversationKey, job, onStream, planStepUpdate) 1134 1176 contextDeadline := daemonruntime.IsContextDeadline(runCtx, runErr) 1135 1177 cancel() 1136 1178 ··· 1157 1199 info.Status = daemonruntime.TaskPending 1158 1200 info.PendingAt = &pendingAt 1159 1201 info.ApprovalRequestID = pendingID 1160 - info.Result = buildConsoleTaskResult(final, agentCtx) 1202 + progressMu.Lock() 1203 + activity := cloneConsoleActivityProgress(latestActivity) 1204 + progressMu.Unlock() 1205 + info.Result = buildConsoleTaskResult(final, agentCtx, activity) 1161 1206 }) 1162 1207 streamTracker.LogSummary("pending") 1163 1208 r.completeHeartbeatTask(job, heartbeatTaskResultSkipped, nil, time.Time{}) ··· 1174 1219 info.Status = daemonruntime.TaskDone 1175 1220 info.Error = "" 1176 1221 info.FinishedAt = &finishedAt 1177 - info.Result = buildConsoleTaskResult(final, agentCtx) 1222 + progressMu.Lock() 1223 + activity := cloneConsoleActivityProgress(latestActivity) 1224 + progressMu.Unlock() 1225 + info.Result = buildConsoleTaskResult(final, agentCtx, activity) 1178 1226 }) 1179 1227 r.maybeRefreshTopicTitle(job, output) 1180 1228 } 1181 1229 1182 - func (r *consoleLocalRuntime) runTask(ctx context.Context, conversationKey string, job consoleLocalTaskJob, onStream llm.StreamHandler) (*agent.Final, *agent.Context, error) { 1230 + func (r *consoleLocalRuntime) runTask(ctx context.Context, conversationKey string, job consoleLocalTaskJob, onStream llm.StreamHandler, planStepUpdate func(*agent.Context, agent.PlanStepUpdate)) (*agent.Final, *agent.Context, error) { 1183 1231 if r == nil { 1184 1232 return nil, nil, fmt.Errorf("console runtime is not initialized") 1185 1233 } ··· 1256 1304 OnStream: onStream, 1257 1305 Meta: meta, 1258 1306 PromptAugment: promptAugment, 1307 + PlanStepUpdate: planStepUpdate, 1259 1308 Memory: memoryHooks, 1260 1309 }) 1261 1310 if err != nil { ··· 1264 1313 return result.Final, result.Context, nil 1265 1314 } 1266 1315 1267 - func buildConsoleTaskResult(final *agent.Final, runCtx *agent.Context) map[string]any { 1316 + func buildConsoleTaskResult(final *agent.Final, runCtx *agent.Context, activity *consoleActivityProgress) map[string]any { 1268 1317 out := map[string]any{ 1269 1318 "final": final, 1319 + } 1320 + if plan := buildConsolePlanProgress(consoleTaskPlan(final, runCtx)); plan != nil { 1321 + out["plan"] = plan 1322 + } 1323 + if activity != nil { 1324 + out["activity"] = cloneConsoleActivityProgress(activity) 1270 1325 } 1271 1326 if runCtx != nil { 1272 1327 out["metrics"] = buildConsoleTaskMetrics(runCtx.Metrics)
+76 -1
cmd/mistermorph/consolecmd/local_runtime_test.go
··· 191 191 ToolCalls: 2, 192 192 ParseRetries: 1, 193 193 }, 194 - }) 194 + }, nil) 195 195 196 196 raw, err := json.Marshal(result) 197 197 if err != nil { ··· 231 231 } 232 232 if _, ok := payload.Metrics["TotalTokens"]; ok { 233 233 t.Fatalf("metrics unexpectedly contains camelCase key: %#v", payload.Metrics) 234 + } 235 + } 236 + 237 + func TestBuildConsoleTaskResultIncludesPlan(t *testing.T) { 238 + result := buildConsoleTaskResult(&agent.Final{Output: "done"}, &agent.Context{ 239 + Plan: &agent.Plan{ 240 + Steps: []agent.PlanStep{ 241 + {Step: "collect logs", Status: agent.PlanStatusCompleted}, 242 + {Step: "patch bug", Status: agent.PlanStatusInProgress}, 243 + }, 244 + }, 245 + }, nil) 246 + 247 + raw, err := json.Marshal(result) 248 + if err != nil { 249 + t.Fatalf("json.Marshal(result) error = %v", err) 250 + } 251 + 252 + var payload struct { 253 + Plan *consolePlanProgress `json:"plan"` 254 + } 255 + if err := json.Unmarshal(raw, &payload); err != nil { 256 + t.Fatalf("json.Unmarshal(result) error = %v", err) 257 + } 258 + if payload.Plan == nil { 259 + t.Fatal("payload.Plan = nil") 260 + } 261 + if len(payload.Plan.Steps) != 2 { 262 + t.Fatalf("len(payload.Plan.Steps) = %d, want 2", len(payload.Plan.Steps)) 263 + } 264 + if payload.Plan.Steps[1].Status != agent.PlanStatusInProgress { 265 + t.Fatalf("payload.Plan.Steps[1].Status = %q, want %q", payload.Plan.Steps[1].Status, agent.PlanStatusInProgress) 266 + } 267 + } 268 + 269 + func TestBuildConsoleTaskResultIncludesActivity(t *testing.T) { 270 + result := buildConsoleTaskResult(&agent.Final{Output: "done"}, &agent.Context{}, &consoleActivityProgress{ 271 + Current: &consoleActivityEntry{ 272 + ID: "tool:1", 273 + Kind: "tool", 274 + Name: "web_search", 275 + Status: "done", 276 + Args: map[string]any{ 277 + "q": "alpha", 278 + }, 279 + }, 280 + History: []consoleActivityEntry{ 281 + { 282 + ID: "tool:1", 283 + Kind: "tool", 284 + Name: "web_search", 285 + Status: "done", 286 + }, 287 + }, 288 + }) 289 + 290 + raw, err := json.Marshal(result) 291 + if err != nil { 292 + t.Fatalf("json.Marshal(result) error = %v", err) 293 + } 294 + 295 + var payload struct { 296 + Activity *consoleActivityProgress `json:"activity"` 297 + } 298 + if err := json.Unmarshal(raw, &payload); err != nil { 299 + t.Fatalf("json.Unmarshal(result) error = %v", err) 300 + } 301 + if payload.Activity == nil || payload.Activity.Current == nil { 302 + t.Fatalf("payload.Activity = %#v, want current activity", payload.Activity) 303 + } 304 + if payload.Activity.Current.Name != "web_search" { 305 + t.Fatalf("payload.Activity.Current.Name = %q, want web_search", payload.Activity.Current.Name) 306 + } 307 + if payload.Activity.Current.Args["q"] != "alpha" { 308 + t.Fatalf("payload.Activity.Current.Args[q] = %#v, want alpha", payload.Activity.Current.Args["q"]) 234 309 } 235 310 } 236 311
+73
cmd/mistermorph/consolecmd/plan_progress.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/quailyquaily/mistermorph/agent" 7 + ) 8 + 9 + type consolePlanProgress struct { 10 + Steps []consolePlanStep `json:"steps,omitempty"` 11 + } 12 + 13 + type consolePlanStep struct { 14 + Step string `json:"step"` 15 + Status string `json:"status,omitempty"` 16 + } 17 + 18 + func cloneConsolePlanProgress(progress *consolePlanProgress) *consolePlanProgress { 19 + if progress == nil { 20 + return nil 21 + } 22 + out := &consolePlanProgress{ 23 + Steps: make([]consolePlanStep, len(progress.Steps)), 24 + } 25 + copy(out.Steps, progress.Steps) 26 + return out 27 + } 28 + 29 + func buildConsolePlanProgress(plan *agent.Plan) *consolePlanProgress { 30 + if plan == nil { 31 + return nil 32 + } 33 + steps := make([]consolePlanStep, 0, len(plan.Steps)) 34 + for _, raw := range plan.Steps { 35 + step := strings.TrimSpace(raw.Step) 36 + if step == "" { 37 + continue 38 + } 39 + steps = append(steps, consolePlanStep{ 40 + Step: step, 41 + Status: strings.TrimSpace(raw.Status), 42 + }) 43 + } 44 + if len(steps) == 0 { 45 + return nil 46 + } 47 + return &consolePlanProgress{Steps: steps} 48 + } 49 + 50 + func consoleTaskPlan(final *agent.Final, runCtx *agent.Context) *agent.Plan { 51 + if runCtx != nil && runCtx.Plan != nil { 52 + return runCtx.Plan 53 + } 54 + if final != nil && final.Plan != nil { 55 + return final.Plan 56 + } 57 + return nil 58 + } 59 + 60 + func buildConsoleTaskProgressResult(plan *agent.Plan, activity *consoleActivityProgress) map[string]any { 61 + progress := buildConsolePlanProgress(plan) 62 + if progress == nil && activity == nil { 63 + return nil 64 + } 65 + out := map[string]any{} 66 + if progress != nil { 67 + out["plan"] = progress 68 + } 69 + if activity != nil { 70 + out["activity"] = cloneConsoleActivityProgress(activity) 71 + } 72 + return out 73 + }
+54
cmd/mistermorph/consolecmd/plan_progress_test.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/quailyquaily/mistermorph/agent" 7 + ) 8 + 9 + func TestBuildConsolePlanProgressSkipsBlankSteps(t *testing.T) { 10 + progress := buildConsolePlanProgress(&agent.Plan{ 11 + Steps: []agent.PlanStep{ 12 + {Step: " scan repo ", Status: agent.PlanStatusCompleted}, 13 + {Step: " ", Status: agent.PlanStatusPending}, 14 + {Step: "patch bug", Status: agent.PlanStatusInProgress}, 15 + }, 16 + }) 17 + if progress == nil { 18 + t.Fatal("progress = nil") 19 + } 20 + if len(progress.Steps) != 2 { 21 + t.Fatalf("len(progress.Steps) = %d, want 2", len(progress.Steps)) 22 + } 23 + if progress.Steps[0].Step != "scan repo" { 24 + t.Fatalf("progress.Steps[0].Step = %q, want %q", progress.Steps[0].Step, "scan repo") 25 + } 26 + if progress.Steps[0].Status != agent.PlanStatusCompleted { 27 + t.Fatalf("progress.Steps[0].Status = %q, want %q", progress.Steps[0].Status, agent.PlanStatusCompleted) 28 + } 29 + if progress.Steps[1].Step != "patch bug" { 30 + t.Fatalf("progress.Steps[1].Step = %q, want %q", progress.Steps[1].Step, "patch bug") 31 + } 32 + if progress.Steps[1].Status != agent.PlanStatusInProgress { 33 + t.Fatalf("progress.Steps[1].Status = %q, want %q", progress.Steps[1].Status, agent.PlanStatusInProgress) 34 + } 35 + } 36 + 37 + func TestBuildConsoleTaskProgressResultIncludesPlan(t *testing.T) { 38 + result := buildConsoleTaskProgressResult(&agent.Plan{ 39 + Steps: []agent.PlanStep{ 40 + {Step: "collect logs", Status: agent.PlanStatusInProgress}, 41 + {Step: "ship fix", Status: agent.PlanStatusPending}, 42 + }, 43 + }, nil) 44 + if result == nil { 45 + t.Fatal("result = nil") 46 + } 47 + progress, ok := result["plan"].(*consolePlanProgress) 48 + if !ok || progress == nil { 49 + t.Fatalf("result.plan = %#v, want *consolePlanProgress", result["plan"]) 50 + } 51 + if len(progress.Steps) != 2 { 52 + t.Fatalf("len(progress.Steps) = %d, want 2", len(progress.Steps)) 53 + } 54 + }
+38 -11
cmd/mistermorph/consolecmd/stream_events.go
··· 27 27 } 28 28 29 29 type consoleEventPreviewSink struct { 30 - hub *consoleStreamHub 31 - taskID string 32 - logger *slog.Logger 33 - now func() time.Time 34 - observer consoleSemanticObserver 35 - observeTimeout time.Duration 36 - observeCtx context.Context 37 - observeCancel context.CancelFunc 38 - observeWake chan struct{} 30 + hub *consoleStreamHub 31 + taskID string 32 + logger *slog.Logger 33 + activityUpdated func(*consoleActivityProgress) 34 + now func() time.Time 35 + observer consoleSemanticObserver 36 + observeTimeout time.Duration 37 + observeCtx context.Context 38 + observeCancel context.CancelFunc 39 + observeWake chan struct{} 39 40 40 41 mu sync.Mutex 42 + activity *consoleActivityProgress 41 43 subtaskLine string 42 44 toolLine string 43 45 stdoutTail string ··· 81 83 return 82 84 } 83 85 86 + activity, activityChanged := s.consumeActivity(event) 87 + if activityChanged && activity != nil { 88 + s.hub.PublishActivity(s.taskID, activity) 89 + if s.activityUpdated != nil { 90 + s.activityUpdated(activity) 91 + } 92 + } 93 + 84 94 text, shouldPublish, observeReq := s.consume(event) 85 95 if observeReq != nil { 86 96 s.enqueueObserve(*observeReq) ··· 88 98 if !shouldPublish || strings.TrimSpace(text) == "" { 89 99 return 90 100 } 91 - s.hub.PublishSnapshot(s.taskID, text) 101 + s.hub.PublishPreview(s.taskID, text) 102 + } 103 + 104 + func (s *consoleEventPreviewSink) consumeActivity(event agent.Event) (*consoleActivityProgress, bool) { 105 + s.mu.Lock() 106 + defer s.mu.Unlock() 107 + 108 + next, changed := updateConsoleActivityProgress(s.activity, event) 109 + if changed { 110 + s.activity = cloneConsoleActivityProgress(next) 111 + } 112 + return next, changed 92 113 } 93 114 94 115 func (s *consoleEventPreviewSink) consume(event agent.Event) (string, bool, *consoleObserveRequest) { ··· 291 312 292 313 func formatConsoleToolStart(event agent.Event) string { 293 314 name := strings.TrimSpace(event.ToolName) 315 + if strings.EqualFold(name, "plan_create") { 316 + return "" 317 + } 294 318 if name == "" { 295 319 name = "tool" 296 320 } ··· 304 328 } 305 329 if errText := strings.TrimSpace(event.Error); errText != "" { 306 330 return fmt.Sprintf("[%s] failed: %s", name, daemonruntime.TruncateUTF8(errText, 160)) 331 + } 332 + if strings.EqualFold(name, "plan_create") { 333 + return "" 307 334 } 308 335 status := strings.TrimSpace(event.Status) 309 336 if status == "" { ··· 387 414 if strings.TrimSpace(text) == "" { 388 415 continue 389 416 } 390 - s.hub.PublishSnapshot(s.taskID, text) 417 + s.hub.PublishPreview(s.taskID, text) 391 418 } 392 419 } 393 420 }
+25
cmd/mistermorph/consolecmd/stream_events_test.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/quailyquaily/mistermorph/agent" 7 + ) 8 + 9 + func TestFormatConsoleToolStartSuppressesPlanCreate(t *testing.T) { 10 + if got := formatConsoleToolStart(agent.Event{ToolName: "plan_create"}); got != "" { 11 + t.Fatalf("formatConsoleToolStart(plan_create) = %q, want empty", got) 12 + } 13 + if got := formatConsoleToolStart(agent.Event{ToolName: "web_search"}); got != "[web_search] running" { 14 + t.Fatalf("formatConsoleToolStart(web_search) = %q, want %q", got, "[web_search] running") 15 + } 16 + } 17 + 18 + func TestFormatConsoleToolDoneSuppressesPlanCreateSuccess(t *testing.T) { 19 + if got := formatConsoleToolDone(agent.Event{ToolName: "plan_create", Status: "done"}); got != "" { 20 + t.Fatalf("formatConsoleToolDone(plan_create success) = %q, want empty", got) 21 + } 22 + if got := formatConsoleToolDone(agent.Event{ToolName: "plan_create", Error: "boom"}); got == "" { 23 + t.Fatal("formatConsoleToolDone(plan_create failure) = empty, want failure preview") 24 + } 25 + }
+40 -6
cmd/mistermorph/consolecmd/streaming.go
··· 17 17 const consoleStreamTicketTTL = 60 * time.Second 18 18 19 19 type consoleStreamFrame struct { 20 - TaskID string `json:"task_id"` 21 - Seq uint64 `json:"seq"` 22 - Status string `json:"status,omitempty"` 23 - Text string `json:"text,omitempty"` 24 - Error string `json:"error,omitempty"` 25 - Done bool `json:"done,omitempty"` 20 + TaskID string `json:"task_id"` 21 + Seq uint64 `json:"seq"` 22 + Status string `json:"status,omitempty"` 23 + Text string `json:"text,omitempty"` 24 + Error string `json:"error,omitempty"` 25 + Plan *consolePlanProgress `json:"plan,omitempty"` 26 + Activity *consoleActivityProgress `json:"activity,omitempty"` 27 + Preview bool `json:"preview,omitempty"` 28 + Done bool `json:"done,omitempty"` 26 29 } 27 30 28 31 type consoleStreamHub struct { ··· 47 50 }) 48 51 } 49 52 53 + func (h *consoleStreamHub) PublishPreview(taskID, text string) { 54 + h.publish(consoleStreamFrame{ 55 + TaskID: strings.TrimSpace(taskID), 56 + Status: "running", 57 + Text: text, 58 + Preview: true, 59 + }) 60 + } 61 + 50 62 func (h *consoleStreamHub) PublishFinal(taskID, text string) { 51 63 h.publish(consoleStreamFrame{ 52 64 TaskID: strings.TrimSpace(taskID), ··· 70 82 h.publish(consoleStreamFrame{ 71 83 TaskID: strings.TrimSpace(taskID), 72 84 Status: strings.TrimSpace(status), 85 + }) 86 + } 87 + 88 + func (h *consoleStreamHub) PublishPlan(taskID string, plan *consolePlanProgress) { 89 + if plan == nil { 90 + return 91 + } 92 + h.publish(consoleStreamFrame{ 93 + TaskID: strings.TrimSpace(taskID), 94 + Status: "running", 95 + Plan: plan, 96 + }) 97 + } 98 + 99 + func (h *consoleStreamHub) PublishActivity(taskID string, activity *consoleActivityProgress) { 100 + if activity == nil { 101 + return 102 + } 103 + h.publish(consoleStreamFrame{ 104 + TaskID: strings.TrimSpace(taskID), 105 + Status: "running", 106 + Activity: cloneConsoleActivityProgress(activity), 73 107 }) 74 108 } 75 109
+86
cmd/mistermorph/consolecmd/streaming_test.go
··· 96 96 } 97 97 } 98 98 99 + func TestConsoleStreamHubPublishesPlanFrame(t *testing.T) { 100 + hub := newConsoleStreamHub() 101 + taskID := "task-plan" 102 + 103 + hub.PublishPlan(taskID, &consolePlanProgress{ 104 + Steps: []consolePlanStep{ 105 + {Step: "scan repo", Status: agent.PlanStatusCompleted}, 106 + {Step: "patch bug", Status: agent.PlanStatusInProgress}, 107 + }, 108 + }) 109 + 110 + frame, ok := hub.Latest(taskID) 111 + if !ok { 112 + t.Fatal("expected plan frame") 113 + } 114 + if frame.Status != "running" { 115 + t.Fatalf("frame.Status = %q, want %q", frame.Status, "running") 116 + } 117 + if frame.Plan == nil { 118 + t.Fatal("frame.Plan = nil") 119 + } 120 + if len(frame.Plan.Steps) != 2 { 121 + t.Fatalf("len(frame.Plan.Steps) = %d, want 2", len(frame.Plan.Steps)) 122 + } 123 + if frame.Plan.Steps[1].Status != agent.PlanStatusInProgress { 124 + t.Fatalf("frame.Plan.Steps[1].Status = %q, want %q", frame.Plan.Steps[1].Status, agent.PlanStatusInProgress) 125 + } 126 + } 127 + 128 + func TestConsoleStreamHubPublishesActivityFrame(t *testing.T) { 129 + hub := newConsoleStreamHub() 130 + taskID := "task-activity" 131 + 132 + hub.PublishActivity(taskID, &consoleActivityProgress{ 133 + Current: &consoleActivityEntry{ 134 + ID: "tool:1", 135 + Kind: "tool", 136 + Name: "web_search", 137 + Status: "running", 138 + Args: map[string]any{ 139 + "q": "alpha", 140 + }, 141 + }, 142 + History: []consoleActivityEntry{ 143 + { 144 + ID: "tool:1", 145 + Kind: "tool", 146 + Name: "web_search", 147 + Status: "running", 148 + }, 149 + }, 150 + }) 151 + 152 + frame, ok := hub.Latest(taskID) 153 + if !ok { 154 + t.Fatal("expected activity frame") 155 + } 156 + if frame.Activity == nil || frame.Activity.Current == nil { 157 + t.Fatalf("frame.Activity = %#v, want current activity", frame.Activity) 158 + } 159 + if frame.Activity.Current.Name != "web_search" { 160 + t.Fatalf("frame.Activity.Current.Name = %q, want web_search", frame.Activity.Current.Name) 161 + } 162 + if frame.Activity.Current.Args["q"] != "alpha" { 163 + t.Fatalf("frame.Activity.Current.Args[q] = %#v, want alpha", frame.Activity.Current.Args["q"]) 164 + } 165 + } 166 + 167 + func TestConsoleStreamHubPublishesPreviewFrame(t *testing.T) { 168 + hub := newConsoleStreamHub() 169 + taskID := "task-preview" 170 + 171 + hub.PublishPreview(taskID, "[web_search] done") 172 + 173 + frame, ok := hub.Latest(taskID) 174 + if !ok { 175 + t.Fatal("expected preview frame") 176 + } 177 + if !frame.Preview { 178 + t.Fatal("frame.Preview = false, want true") 179 + } 180 + if frame.Text != "[web_search] done" { 181 + t.Fatalf("frame.Text = %q, want %q", frame.Text, "[web_search] done") 182 + } 183 + } 184 + 99 185 func TestConsoleEventPreviewSinkLongShellThrottlesOutput(t *testing.T) { 100 186 hub := newConsoleStreamHub() 101 187 sink := newConsoleEventPreviewSink(hub, "task-throttle", nil)
+30
web/console/src/i18n/index.js
··· 296 296 chat_polling_action_weigh: "weighing options", 297 297 chat_polling_action_reflect: "reflecting", 298 298 chat_polling_action_tinker: "tinkering", 299 + chat_plan_title: "Plan Progress", 300 + chat_plan_summary: "{completed}/{total} completed", 301 + chat_plan_step_pending: "pending", 302 + chat_plan_step_in_progress: "in progress", 303 + chat_plan_step_completed: "completed", 304 + chat_activity_title: "Activity", 305 + chat_activity_kind_tool: "Tool", 306 + chat_activity_kind_subtask: "Subtask", 307 + chat_activity_history_show: "Show {count} earlier", 308 + chat_activity_history_hide: "Hide history", 299 309 chat_history_loading: "Loading chat history...", 300 310 chat_topics_loading: "Loading topics...", 301 311 chat_result_empty: "Task finished with no output.", ··· 937 947 chat_polling_action_weigh: "斟酌", 938 948 chat_polling_action_reflect: "深思", 939 949 chat_polling_action_tinker: "捣鼓", 950 + chat_plan_title: "计划进度", 951 + chat_plan_summary: "已完成 {completed}/{total}", 952 + chat_plan_step_pending: "待开始", 953 + chat_plan_step_in_progress: "进行中", 954 + chat_plan_step_completed: "已完成", 955 + chat_activity_title: "执行状态", 956 + chat_activity_kind_tool: "工具", 957 + chat_activity_kind_subtask: "子任务", 958 + chat_activity_history_show: "展开更早的 {count} 条", 959 + chat_activity_history_hide: "收起历史", 940 960 chat_history_loading: "正在加载聊天记录...", 941 961 chat_topics_loading: "正在加载话题...", 942 962 chat_result_empty: "任务完成,但没有返回内容。", ··· 1578 1598 chat_polling_action_weigh: "吟味中", 1579 1599 chat_polling_action_reflect: "熟考中", 1580 1600 chat_polling_action_tinker: "試行中", 1601 + chat_plan_title: "進行プラン", 1602 + chat_plan_summary: "{completed}/{total} 完了", 1603 + chat_plan_step_pending: "未着手", 1604 + chat_plan_step_in_progress: "進行中", 1605 + chat_plan_step_completed: "完了", 1606 + chat_activity_title: "実行状況", 1607 + chat_activity_kind_tool: "Tool", 1608 + chat_activity_kind_subtask: "Subtask", 1609 + chat_activity_history_show: "前の {count} 件を表示", 1610 + chat_activity_history_hide: "履歴を隠す", 1581 1611 chat_history_loading: "チャット履歴を読み込み中...", 1582 1612 chat_topics_loading: "トピックを読み込み中...", 1583 1613 chat_result_empty: "タスクは完了しましたが出力がありません。",
+485 -1
web/console/src/views/ChatView.css
··· 1006 1006 width: 100%; 1007 1007 } 1008 1008 1009 + .chat-history-stack { 1010 + width: min(100%, 82ch); 1011 + min-width: 0; 1012 + display: grid; 1013 + gap: 8px; 1014 + } 1015 + 1009 1016 .chat-history-copy, 1010 1017 .chat-history-bubble { 1011 1018 position: relative; ··· 1015 1022 gap: 8px; 1016 1023 } 1017 1024 1025 + .chat-plan-card { 1026 + --chat-plan-border: color-mix(in srgb, var(--accent-1) 24%, var(--line)); 1027 + --chat-plan-inner-border: color-mix(in srgb, var(--accent-1) 12%, var(--line)); 1028 + width: min(100%, 82ch); 1029 + min-width: 0; 1030 + position: relative; 1031 + display: grid; 1032 + gap: 0; 1033 + padding: 13px 14px 12px; 1034 + overflow: hidden; 1035 + border: 1px dashed var(--chat-plan-border); 1036 + border-radius: 2px; 1037 + background: color-mix(in srgb, var(--q-bg-paper) 95%, transparent); 1038 + box-shadow: none; 1039 + } 1040 + 1041 + .chat-plan-card::before { 1042 + content: ""; 1043 + position: absolute; 1044 + inset: 4px; 1045 + border: 1px solid var(--chat-plan-inner-border); 1046 + pointer-events: none; 1047 + z-index: 0; 1048 + } 1049 + 1050 + .chat-plan-head { 1051 + min-width: 0; 1052 + display: flex; 1053 + align-items: flex-start; 1054 + justify-content: space-between; 1055 + gap: 10px; 1056 + padding-bottom: 10px; 1057 + position: relative; 1058 + z-index: 1; 1059 + } 1060 + 1061 + .chat-plan-head-copy { 1062 + min-width: 0; 1063 + display: grid; 1064 + gap: 4px; 1065 + } 1066 + 1067 + .chat-plan-kicker, 1068 + .chat-plan-meta, 1069 + .chat-plan-step-text, 1070 + .chat-plan-step-status { 1071 + margin: 0; 1072 + } 1073 + 1074 + .chat-plan-kicker { 1075 + width: fit-content; 1076 + } 1077 + 1078 + .chat-plan-meta { 1079 + font-size: 11px; 1080 + line-height: 1.55; 1081 + color: var(--text-2); 1082 + } 1083 + 1084 + .chat-plan-state { 1085 + flex: 0 0 auto; 1086 + display: inline-flex; 1087 + align-items: center; 1088 + gap: 7px; 1089 + min-height: 18px; 1090 + padding-top: 1px; 1091 + font-family: var(--font-mono); 1092 + font-size: 10px; 1093 + line-height: 1; 1094 + letter-spacing: 0.14em; 1095 + text-transform: uppercase; 1096 + color: var(--text-2); 1097 + } 1098 + 1099 + .chat-plan-state::before { 1100 + content: ""; 1101 + width: 8px; 1102 + height: 8px; 1103 + border-radius: 1px; 1104 + background: color-mix(in srgb, var(--line) 80%, var(--q-bg-paper)); 1105 + } 1106 + 1107 + .chat-plan-state.is-completed { 1108 + color: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1109 + } 1110 + 1111 + .chat-plan-state.is-completed::before { 1112 + background: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1113 + } 1114 + 1115 + .chat-plan-state.is-in-progress { 1116 + color: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1117 + } 1118 + 1119 + .chat-plan-state.is-in-progress::before { 1120 + background: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1121 + } 1122 + 1123 + .chat-plan-list { 1124 + position: relative; 1125 + margin: 0; 1126 + padding: 2px 0 0; 1127 + list-style: none; 1128 + display: grid; 1129 + gap: 0; 1130 + z-index: 1; 1131 + } 1132 + 1133 + .chat-plan-list::before { 1134 + content: ""; 1135 + position: absolute; 1136 + left: 3px; 1137 + top: 8px; 1138 + bottom: 8px; 1139 + width: 1px; 1140 + background: repeating-linear-gradient( 1141 + 180deg, 1142 + color-mix(in srgb, var(--accent-1) 22%, transparent) 0 3px, 1143 + transparent 6px 9px 1144 + ); 1145 + opacity: 0.78; 1146 + } 1147 + 1148 + .chat-plan-step { 1149 + min-width: 0; 1150 + display: grid; 1151 + grid-template-columns: auto minmax(0, 1fr); 1152 + align-items: start; 1153 + gap: 12px; 1154 + padding: 11px 0 12px; 1155 + } 1156 + 1157 + .chat-plan-step + .chat-plan-step { 1158 + border-top: 1px solid color-mix(in srgb, var(--line-soft) 54%, transparent); 1159 + } 1160 + 1161 + .chat-plan-step-dot { 1162 + width: 8px; 1163 + height: 8px; 1164 + margin-top: 6px; 1165 + border-radius: 1px; 1166 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--q-bg-paper) 94%, transparent); 1167 + background: color-mix(in srgb, var(--line) 76%, var(--q-bg-paper)); 1168 + } 1169 + 1170 + .chat-plan-step.is-pending .chat-plan-step-dot { 1171 + background: color-mix(in srgb, var(--line) 76%, var(--q-bg-paper)); 1172 + } 1173 + 1174 + .chat-plan-step.is-in-progress .chat-plan-step-dot { 1175 + background: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1176 + } 1177 + 1178 + .chat-plan-step.is-completed .chat-plan-step-dot { 1179 + background: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1180 + } 1181 + 1182 + .chat-plan-step-copy { 1183 + min-width: 0; 1184 + display: grid; 1185 + gap: 4px; 1186 + } 1187 + 1188 + .chat-plan-step-text { 1189 + font-family: var(--q-card-title-font-family); 1190 + font-size: clamp(1rem, 0.96rem + 0.12vw, 1.05rem); 1191 + font-weight: 400; 1192 + line-height: 1.14; 1193 + letter-spacing: -0.025em; 1194 + color: var(--text-0); 1195 + word-break: break-word; 1196 + overflow-wrap: anywhere; 1197 + } 1198 + 1199 + .chat-plan-step-status { 1200 + font-size: 10px; 1201 + line-height: 1.25; 1202 + letter-spacing: 0.14em; 1203 + font-family: var(--font-mono); 1204 + text-transform: uppercase; 1205 + color: var(--text-2); 1206 + } 1207 + 1208 + .chat-plan-step.is-completed .chat-plan-step-status { 1209 + color: color-mix(in srgb, var(--q-c-green) 82%, var(--text-2)); 1210 + } 1211 + 1212 + .chat-plan-step.is-in-progress .chat-plan-step-status { 1213 + color: color-mix(in srgb, var(--q-c-orange) 82%, var(--text-2)); 1214 + } 1215 + 1216 + .chat-activity-card { 1217 + --chat-activity-border: color-mix(in srgb, var(--accent-1) 18%, var(--line)); 1218 + --chat-activity-inner-border: color-mix(in srgb, var(--accent-1) 10%, var(--line)); 1219 + width: min(100%, 82ch); 1220 + min-width: 0; 1221 + position: relative; 1222 + display: grid; 1223 + gap: 0; 1224 + padding: 13px 14px 12px; 1225 + overflow: hidden; 1226 + border: 1px dashed var(--chat-activity-border); 1227 + border-radius: 2px; 1228 + background: color-mix(in srgb, var(--q-bg-paper) 96%, transparent); 1229 + box-shadow: none; 1230 + } 1231 + 1232 + .chat-activity-card::before { 1233 + content: ""; 1234 + position: absolute; 1235 + inset: 4px; 1236 + border: 1px solid var(--chat-activity-inner-border); 1237 + pointer-events: none; 1238 + z-index: 0; 1239 + } 1240 + 1241 + .chat-activity-head, 1242 + .chat-activity-entry, 1243 + .chat-activity-history { 1244 + position: relative; 1245 + z-index: 1; 1246 + } 1247 + 1248 + .chat-activity-head { 1249 + min-width: 0; 1250 + display: flex; 1251 + align-items: flex-start; 1252 + justify-content: space-between; 1253 + gap: 10px; 1254 + padding-bottom: 10px; 1255 + } 1256 + 1257 + .chat-activity-head-copy { 1258 + min-width: 0; 1259 + display: grid; 1260 + gap: 4px; 1261 + } 1262 + 1263 + .chat-activity-kicker, 1264 + .chat-activity-meta, 1265 + .chat-activity-kind, 1266 + .chat-activity-name, 1267 + .chat-activity-note { 1268 + margin: 0; 1269 + } 1270 + 1271 + .chat-activity-meta { 1272 + font-size: 11px; 1273 + line-height: 1.55; 1274 + color: var(--text-2); 1275 + } 1276 + 1277 + .chat-activity-kicker { 1278 + width: fit-content; 1279 + } 1280 + 1281 + .chat-activity-state { 1282 + flex: 0 0 auto; 1283 + display: inline-flex; 1284 + align-items: center; 1285 + gap: 7px; 1286 + min-height: 18px; 1287 + padding-top: 1px; 1288 + font-family: var(--font-mono); 1289 + font-size: 10px; 1290 + line-height: 1; 1291 + letter-spacing: 0.14em; 1292 + text-transform: uppercase; 1293 + color: var(--text-2); 1294 + } 1295 + 1296 + .chat-activity-state::before { 1297 + content: ""; 1298 + width: 8px; 1299 + height: 8px; 1300 + border-radius: 1px; 1301 + background: color-mix(in srgb, var(--line) 80%, var(--q-bg-paper)); 1302 + } 1303 + 1304 + .chat-activity-state.is-done { 1305 + color: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1306 + } 1307 + 1308 + .chat-activity-state.is-done::before { 1309 + background: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1310 + } 1311 + 1312 + .chat-activity-state.is-running { 1313 + color: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1314 + } 1315 + 1316 + .chat-activity-state.is-running::before { 1317 + background: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1318 + } 1319 + 1320 + .chat-activity-state.is-failed { 1321 + color: color-mix(in srgb, var(--q-c-red) 86%, var(--q-c-dark)); 1322 + } 1323 + 1324 + .chat-activity-state.is-failed::before { 1325 + background: color-mix(in srgb, var(--q-c-red) 86%, var(--q-c-dark)); 1326 + } 1327 + 1328 + .chat-activity-entry { 1329 + min-width: 0; 1330 + display: grid; 1331 + grid-template-columns: auto minmax(0, 1fr); 1332 + align-items: start; 1333 + gap: 12px; 1334 + } 1335 + 1336 + .chat-activity-dot { 1337 + width: 8px; 1338 + height: 8px; 1339 + margin-top: 7px; 1340 + border-radius: 1px; 1341 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--q-bg-paper) 94%, transparent); 1342 + background: color-mix(in srgb, var(--line) 76%, var(--q-bg-paper)); 1343 + } 1344 + 1345 + .chat-activity-entry.is-running .chat-activity-dot { 1346 + background: color-mix(in srgb, var(--q-c-orange) 82%, var(--q-c-dark)); 1347 + } 1348 + 1349 + .chat-activity-entry.is-done .chat-activity-dot { 1350 + background: color-mix(in srgb, var(--q-c-green) 82%, var(--q-c-dark)); 1351 + } 1352 + 1353 + .chat-activity-entry.is-failed .chat-activity-dot { 1354 + background: color-mix(in srgb, var(--q-c-red) 86%, var(--q-c-dark)); 1355 + } 1356 + 1357 + .chat-activity-copy { 1358 + min-width: 0; 1359 + display: grid; 1360 + gap: 5px; 1361 + } 1362 + 1363 + .chat-activity-line { 1364 + min-width: 0; 1365 + display: flex; 1366 + align-items: baseline; 1367 + flex-wrap: wrap; 1368 + gap: 7px; 1369 + } 1370 + 1371 + .chat-activity-kind, 1372 + .chat-activity-history-status { 1373 + font-family: var(--font-mono); 1374 + font-size: 10px; 1375 + line-height: 1.25; 1376 + letter-spacing: 0.14em; 1377 + text-transform: uppercase; 1378 + color: var(--text-2); 1379 + } 1380 + 1381 + .chat-activity-name { 1382 + font-family: var(--font-mono); 1383 + font-size: 10px; 1384 + line-height: 1.55; 1385 + color: var(--text-0); 1386 + word-break: break-word; 1387 + overflow-wrap: anywhere; 1388 + } 1389 + 1390 + .chat-activity-entry.is-running .chat-activity-history-status { 1391 + color: color-mix(in srgb, var(--q-c-orange) 82%, var(--text-2)); 1392 + } 1393 + 1394 + .chat-activity-entry.is-done .chat-activity-history-status { 1395 + color: color-mix(in srgb, var(--q-c-green) 82%, var(--text-2)); 1396 + } 1397 + 1398 + .chat-activity-entry.is-failed .chat-activity-history-status { 1399 + color: color-mix(in srgb, var(--q-c-red) 86%, var(--text-2)); 1400 + } 1401 + 1402 + .chat-activity-params { 1403 + min-width: 0; 1404 + display: flex; 1405 + flex-wrap: wrap; 1406 + gap: 4px 10px; 1407 + } 1408 + 1409 + .chat-activity-param { 1410 + display: inline-flex; 1411 + align-items: baseline; 1412 + gap: 5px; 1413 + min-width: 0; 1414 + font-family: var(--font-mono); 1415 + font-size: 11px; 1416 + line-height: 1.5; 1417 + } 1418 + 1419 + .chat-activity-param-key { 1420 + color: var(--text-2); 1421 + text-transform: lowercase; 1422 + } 1423 + 1424 + .chat-activity-param-value { 1425 + color: var(--text-1); 1426 + word-break: break-word; 1427 + overflow-wrap: anywhere; 1428 + } 1429 + 1430 + .chat-activity-note { 1431 + font-size: 12px; 1432 + line-height: 1.55; 1433 + color: var(--text-1); 1434 + word-break: break-word; 1435 + overflow-wrap: anywhere; 1436 + } 1437 + 1438 + .chat-activity-history { 1439 + min-width: 0; 1440 + display: grid; 1441 + gap: 10px; 1442 + padding-top: 10px; 1443 + } 1444 + 1445 + .chat-activity-toggle { 1446 + width: fit-content; 1447 + padding: 0; 1448 + border: 0; 1449 + background: transparent; 1450 + font-family: var(--font-mono); 1451 + font-size: 10px; 1452 + line-height: 1.25; 1453 + letter-spacing: 0.14em; 1454 + text-transform: uppercase; 1455 + color: var(--text-2); 1456 + cursor: pointer; 1457 + outline: 1px solid transparent; 1458 + outline-offset: 2px; 1459 + } 1460 + 1461 + .chat-activity-toggle:hover, 1462 + .chat-activity-toggle:focus-visible { 1463 + color: var(--text-0); 1464 + } 1465 + 1466 + .chat-activity-toggle:focus-visible { 1467 + outline-color: color-mix(in srgb, var(--accent-1) 42%, transparent); 1468 + } 1469 + 1470 + .chat-activity-list { 1471 + position: relative; 1472 + margin: 0; 1473 + padding: 0; 1474 + list-style: none; 1475 + display: grid; 1476 + gap: 10px; 1477 + } 1478 + 1479 + .chat-activity-list::before { 1480 + content: ""; 1481 + position: absolute; 1482 + left: 3px; 1483 + top: 8px; 1484 + bottom: 8px; 1485 + width: 1px; 1486 + background: repeating-linear-gradient( 1487 + 180deg, 1488 + color-mix(in srgb, var(--accent-1) 18%, transparent) 0 3px, 1489 + transparent 6px 9px 1490 + ); 1491 + opacity: 0.72; 1492 + } 1493 + 1018 1494 .chat-history-copy { 1019 1495 padding: 18px 20px 20px; 1020 1496 border: 1px solid color-mix(in srgb, var(--line-soft) 80%, transparent); ··· 1206 1682 padding-right: clamp(10px, 2vw, 36px); 1207 1683 } 1208 1684 1685 + .chat-history-agent .chat-history-stack > .chat-history-copy, 1686 + .chat-history-agent .chat-history-stack > .chat-plan-card { 1687 + width: 100%; 1688 + } 1689 + 1209 1690 .chat-history-agent .chat-history-copy { 1210 1691 width: min(100%, 82ch); 1211 1692 } ··· 1359 1840 } 1360 1841 1361 1842 .chat-history-copy, 1362 - .chat-history-bubble { 1843 + .chat-history-bubble, 1844 + .chat-history-stack, 1845 + .chat-plan-card, 1846 + .chat-activity-card { 1363 1847 width: 100%; 1364 1848 } 1365 1849 }
+452 -17
web/console/src/views/ChatView.js
··· 395 395 return ""; 396 396 } 397 397 398 + function normalizePlanStatus(raw) { 399 + const value = String(raw || "").trim().toLowerCase(); 400 + switch (value) { 401 + case "completed": 402 + case "in_progress": 403 + case "pending": 404 + return value; 405 + default: 406 + return "pending"; 407 + } 408 + } 409 + 410 + function normalizePlan(raw) { 411 + const steps = Array.isArray(raw?.steps) 412 + ? raw.steps 413 + .map((step) => ({ 414 + step: String(step?.step || "").trim(), 415 + status: normalizePlanStatus(step?.status), 416 + })) 417 + .filter((step) => step.step) 418 + : []; 419 + if (steps.length === 0) { 420 + return null; 421 + } 422 + return { steps }; 423 + } 424 + 425 + function taskPlan(task) { 426 + return normalizePlan(task?.result?.plan || task?.result?.final?.plan); 427 + } 428 + 429 + function normalizeActivityKind(raw) { 430 + const value = String(raw || "").trim().toLowerCase(); 431 + switch (value) { 432 + case "tool": 433 + case "subtask": 434 + return value; 435 + default: 436 + return ""; 437 + } 438 + } 439 + 440 + function normalizeActivityEntry(raw) { 441 + const id = String(raw?.id || "").trim(); 442 + const kind = normalizeActivityKind(raw?.kind); 443 + if (!id || !kind) { 444 + return null; 445 + } 446 + const args = 447 + raw?.args && typeof raw.args === "object" && !Array.isArray(raw.args) 448 + ? Object.fromEntries( 449 + Object.entries(raw.args) 450 + .map(([key, value]) => [String(key || "").trim(), value]) 451 + .filter(([key]) => key) 452 + ) 453 + : null; 454 + return { 455 + id, 456 + kind, 457 + name: String(raw?.name || "").trim(), 458 + status: normalizeTaskStatus(raw?.status), 459 + args: args && Object.keys(args).length > 0 ? args : null, 460 + summary: String(raw?.summary || "").trim(), 461 + error: String(raw?.error || "").trim(), 462 + taskId: String(raw?.task_id || "").trim(), 463 + mode: String(raw?.mode || "").trim(), 464 + profile: String(raw?.profile || "").trim(), 465 + outputKind: String(raw?.output_kind || "").trim(), 466 + }; 467 + } 468 + 469 + function normalizeActivity(raw) { 470 + const history = Array.isArray(raw?.history) 471 + ? raw.history.map((entry) => normalizeActivityEntry(entry)).filter(Boolean) 472 + : []; 473 + const current = normalizeActivityEntry(raw?.current) || history[history.length - 1] || null; 474 + if (!current && history.length === 0) { 475 + return null; 476 + } 477 + return { 478 + current, 479 + history, 480 + }; 481 + } 482 + 483 + function taskActivity(task) { 484 + return normalizeActivity(task?.result?.activity); 485 + } 486 + 487 + function activityCurrentEntry(activity) { 488 + if (!activity) { 489 + return null; 490 + } 491 + return activity.current || activity.history[activity.history.length - 1] || null; 492 + } 493 + 494 + function activityHistoryEntries(activity) { 495 + if (!Array.isArray(activity?.history) || activity.history.length <= 1) { 496 + return []; 497 + } 498 + return activity.history.slice(0, -1).reverse(); 499 + } 500 + 501 + function activityHistoryCount(activity) { 502 + return activityHistoryEntries(activity).length; 503 + } 504 + 505 + function activityState(activity) { 506 + return normalizeTaskStatus(activityCurrentEntry(activity)?.status); 507 + } 508 + 509 + function activityStateClass(activity) { 510 + return `chat-activity-state is-${activityState(activity).replaceAll("_", "-")}`; 511 + } 512 + 513 + function activityEntryClass(entry) { 514 + return `chat-activity-entry is-${normalizeTaskStatus(entry?.status).replaceAll("_", "-")}`; 515 + } 516 + 517 + function activityStatusLabel(entry, t) { 518 + return t(`status_${normalizeTaskStatus(entry?.status)}`); 519 + } 520 + 521 + function activityKindLabel(entry, t) { 522 + switch (normalizeActivityKind(entry?.kind)) { 523 + case "tool": 524 + return t("chat_activity_kind_tool"); 525 + case "subtask": 526 + return t("chat_activity_kind_subtask"); 527 + default: 528 + return ""; 529 + } 530 + } 531 + 532 + function activityEntryTitle(entry) { 533 + const name = String(entry?.name || "").trim(); 534 + if (name) { 535 + return name; 536 + } 537 + return normalizeActivityKind(entry?.kind) || "activity"; 538 + } 539 + 540 + function activityParamValueText(value) { 541 + if (value === null || value === undefined) { 542 + return ""; 543 + } 544 + if (typeof value === "string") { 545 + return value.trim(); 546 + } 547 + if (typeof value === "number" || typeof value === "boolean") { 548 + return String(value); 549 + } 550 + try { 551 + return JSON.stringify(value); 552 + } catch { 553 + return String(value); 554 + } 555 + } 556 + 557 + function truncateActivityParamValue(raw) { 558 + const text = String(raw || "").trim(); 559 + if (text.length <= 120) { 560 + return text; 561 + } 562 + return `${text.slice(0, 117)}...`; 563 + } 564 + 565 + function activityParams(entry) { 566 + const items = []; 567 + if (entry?.args && typeof entry.args === "object" && !Array.isArray(entry.args)) { 568 + for (const key of Object.keys(entry.args).sort()) { 569 + const value = truncateActivityParamValue(activityParamValueText(entry.args[key])); 570 + if (!value) { 571 + continue; 572 + } 573 + items.push({ key, value }); 574 + } 575 + } 576 + if (normalizeActivityKind(entry?.kind) === "subtask") { 577 + const extras = [ 578 + ["task_id", entry?.taskId], 579 + ["mode", entry?.mode], 580 + ["profile", entry?.profile], 581 + ["output", entry?.outputKind], 582 + ]; 583 + for (const [key, rawValue] of extras) { 584 + const value = truncateActivityParamValue(activityParamValueText(rawValue)); 585 + if (!value) { 586 + continue; 587 + } 588 + items.push({ key, value }); 589 + } 590 + } 591 + return items; 592 + } 593 + 594 + function activityEntryNote(entry) { 595 + const errorText = String(entry?.error || "").trim(); 596 + if (errorText) { 597 + return errorText; 598 + } 599 + return String(entry?.summary || "").trim(); 600 + } 601 + 602 + function activityHistoryToggleLabel(activity, expanded, t) { 603 + if (expanded) { 604 + return t("chat_activity_history_hide"); 605 + } 606 + return t("chat_activity_history_show", { 607 + count: activityHistoryCount(activity), 608 + }); 609 + } 610 + 611 + function planCompletedCount(plan) { 612 + if (!Array.isArray(plan?.steps)) { 613 + return 0; 614 + } 615 + return plan.steps.filter((step) => step.status === "completed").length; 616 + } 617 + 618 + function planTotalCount(plan) { 619 + return Array.isArray(plan?.steps) ? plan.steps.length : 0; 620 + } 621 + 622 + function planState(plan) { 623 + const total = planTotalCount(plan); 624 + if (total === 0) { 625 + return "pending"; 626 + } 627 + const completed = planCompletedCount(plan); 628 + if (completed >= total) { 629 + return "completed"; 630 + } 631 + if (plan.steps.some((step) => step.status === "in_progress" || step.status === "completed")) { 632 + return "in_progress"; 633 + } 634 + return "pending"; 635 + } 636 + 637 + function planStateLabel(plan, t) { 638 + switch (planState(plan)) { 639 + case "completed": 640 + return t("chat_plan_step_completed"); 641 + case "in_progress": 642 + return t("chat_plan_step_in_progress"); 643 + default: 644 + return t("chat_plan_step_pending"); 645 + } 646 + } 647 + 648 + function planSummaryText(plan, t) { 649 + return t("chat_plan_summary", { 650 + completed: planCompletedCount(plan), 651 + total: planTotalCount(plan), 652 + }); 653 + } 654 + 655 + function planStepStatusLabel(step, t) { 656 + switch (normalizePlanStatus(step?.status)) { 657 + case "completed": 658 + return t("chat_plan_step_completed"); 659 + case "in_progress": 660 + return t("chat_plan_step_in_progress"); 661 + default: 662 + return t("chat_plan_step_pending"); 663 + } 664 + } 665 + 666 + function planStepClass(step) { 667 + return `chat-plan-step is-${normalizePlanStatus(step?.status).replaceAll("_", "-")}`; 668 + } 669 + 670 + function planStateClass(plan) { 671 + return `chat-plan-state is-${planState(plan).replaceAll("_", "-")}`; 672 + } 673 + 398 674 function stableHash(raw) { 399 675 const text = String(raw || ""); 400 676 let hash = 2166136261; ··· 445 721 if (isTerminalStatus(status)) { 446 722 return t("chat_result_empty"); 447 723 } 724 + if (taskPlan(task) || taskActivity(task)) { 725 + return ""; 726 + } 448 727 const pendingText = String(options.pendingText || "").trim(); 449 728 if (pendingText) { 450 729 return pendingText; ··· 477 756 agentName: options.agentName, 478 757 pendingSeed: taskID, 479 758 }), 759 + plan: taskPlan(task), 760 + activity: taskActivity(task), 480 761 status: normalizeTaskStatus(task?.status), 481 762 timeText: historyTimeLabel(task?.finished_at), 482 763 taskId: taskID, ··· 565 846 const rawRevealItemID = ref(""); 566 847 const rawRevealCount = ref(0); 567 848 const heartbeatRevealCount = ref(0); 849 + const activityExpandedState = ref({}); 568 850 const historyAutoStick = ref(true); 569 851 let rawRevealTimerID = 0; 570 852 let heartbeatRevealTimerID = 0; ··· 1545 1827 return String(item?.role || "").trim().toLowerCase() === "agent" && !historyItemRenderReady(item); 1546 1828 } 1547 1829 1830 + function showHistoryAgentBubble(item) { 1831 + return String(item?.text || "") !== ""; 1832 + } 1833 + 1834 + function activityExpanded(itemID) { 1835 + const key = String(itemID || "").trim(); 1836 + return key !== "" && activityExpandedState.value[key] === true; 1837 + } 1838 + 1839 + function toggleActivityExpanded(itemID) { 1840 + const key = String(itemID || "").trim(); 1841 + if (!key) { 1842 + return; 1843 + } 1844 + activityExpandedState.value = { 1845 + ...activityExpandedState.value, 1846 + [key]: !activityExpanded(key), 1847 + }; 1848 + } 1849 + 1548 1850 function markHistoryItemRendered(itemID) { 1549 1851 const key = String(itemID || "").trim(); 1550 1852 if (key && renderedHistoryItems.value[key] !== true) { ··· 1656 1958 if (!frame || typeof frame !== "object") { 1657 1959 return; 1658 1960 } 1961 + const existingItem = chatHistoryItems.value.find((item) => item.id === historyID) || null; 1962 + const nextPlan = normalizePlan(frame.plan || existingItem?.plan); 1963 + const nextActivity = normalizeActivity(frame.activity || existingItem?.activity); 1964 + const nextStatus = normalizeTaskStatus(frame.status || existingItem?.status); 1965 + const pendingSeed = historyPendingSeed(existingItem, key); 1966 + const isPreview = frame.preview === true; 1659 1967 const patch = {}; 1660 - if (typeof frame.text === "string" && frame.text !== "") { 1968 + if (frame.plan && typeof frame.plan === "object") { 1969 + patch.plan = nextPlan; 1970 + } 1971 + if (frame.activity && typeof frame.activity === "object") { 1972 + patch.activity = nextActivity; 1973 + } 1974 + if (!isPreview && typeof frame.text === "string" && frame.text !== "") { 1661 1975 patch.text = frame.text; 1662 - } else if (typeof frame.error === "string" && frame.error !== "") { 1976 + } else if (!isPreview && typeof frame.error === "string" && frame.error !== "") { 1663 1977 patch.text = frame.error; 1978 + } 1979 + if ((isPreview || frame.plan || frame.activity) && (nextPlan || nextActivity) && !isTerminalStatus(nextStatus) && typeof patch.text !== "string") { 1980 + patch.text = ""; 1664 1981 } 1665 1982 if (typeof frame.status === "string" && frame.status !== "") { 1666 1983 patch.status = normalizeTaskStatus(frame.status); ··· 1779 2096 id: newHistoryID(), 1780 2097 role: String(partial?.role || "system"), 1781 2098 text: String(partial?.text || ""), 2099 + plan: normalizePlan(partial?.plan), 2100 + activity: normalizeActivity(partial?.activity), 1782 2101 status: String(partial?.status || ""), 1783 2102 timeText: String(partial?.timeText || ""), 1784 2103 taskId: String(partial?.taskId || ""), ··· 1842 2161 const existingItem = chatHistoryItems.value.find((item) => item.id === resolvedHistoryID) || null; 1843 2162 const pendingSeed = historyPendingSeed(existingItem, taskID); 1844 2163 patchAgentHistoryItem(taskID, historyID, { 2164 + plan: taskPlan(detail), 2165 + activity: taskActivity(detail), 1845 2166 status, 1846 2167 text: taskAgentText(detail, t, { 1847 2168 agentName: activeAgentName.value, ··· 2439 2760 historyClass, 2440 2761 historySurfaceClass, 2441 2762 markHistoryItemRendered, 2763 + showHistoryAgentBubble, 2442 2764 showHistorySkeleton, 2765 + activityCurrentEntry, 2766 + activityExpanded, 2767 + activityEntryClass, 2768 + activityEntryNote, 2769 + activityEntryTitle, 2770 + activityHistoryCount, 2771 + activityHistoryEntries, 2772 + activityHistoryToggleLabel, 2773 + activityKindLabel, 2774 + activityParams, 2775 + activityStateClass, 2776 + activityStatusLabel, 2777 + toggleActivityExpanded, 2778 + planSummaryText, 2779 + planStateLabel, 2780 + planStateClass, 2781 + planStepClass, 2782 + planStepStatusLabel, 2443 2783 clickHistoryTime, 2444 2784 openRawDialog, 2445 2785 closeRawDialog, ··· 2585 2925 > 2586 2926 {{ item.timeText }} 2587 2927 </code> 2588 - <div :class="historySurfaceClass(item)"> 2589 - <template v-if="item.role === 'agent'"> 2590 - <div v-if="showHistorySkeleton(item)" class="chat-history-skeleton" aria-hidden="true"> 2591 - <QSkeleton variant="text" width="92%" /> 2592 - <QSkeleton variant="text" width="100%" /> 2593 - <QSkeleton variant="text" width="68%" /> 2928 + <template v-if="item.role === 'agent'"> 2929 + <div class="chat-history-stack"> 2930 + <section v-if="item.plan" class="chat-plan-card"> 2931 + <header class="chat-plan-head"> 2932 + <div class="chat-plan-head-copy"> 2933 + <p class="ui-kicker chat-plan-kicker">{{ t("chat_plan_title") }}</p> 2934 + <p class="chat-plan-meta">{{ planSummaryText(item.plan, t) }}</p> 2935 + </div> 2936 + <span :class="planStateClass(item.plan)">{{ planStateLabel(item.plan, t) }}</span> 2937 + </header> 2938 + <ol class="chat-plan-list"> 2939 + <li 2940 + v-for="(step, stepIndex) in item.plan.steps" 2941 + :key="item.id + ':plan:' + stepIndex" 2942 + :class="planStepClass(step)" 2943 + > 2944 + <span class="chat-plan-step-dot" aria-hidden="true"></span> 2945 + <div class="chat-plan-step-copy"> 2946 + <p class="chat-plan-step-text">{{ step.step }}</p> 2947 + <p class="chat-plan-step-status">{{ planStepStatusLabel(step, t) }}</p> 2948 + </div> 2949 + </li> 2950 + </ol> 2951 + </section> 2952 + <section v-if="item.activity" class="chat-activity-card"> 2953 + <header class="chat-activity-head"> 2954 + <div class="chat-activity-head-copy"> 2955 + <p class="ui-kicker chat-activity-kicker">{{ t("chat_activity_title") }}</p> 2956 + </div> 2957 + <span :class="activityStateClass(item.activity)">{{ activityStatusLabel(activityCurrentEntry(item.activity), t) }}</span> 2958 + </header> 2959 + <div 2960 + v-if="activityCurrentEntry(item.activity)" 2961 + :class="activityEntryClass(activityCurrentEntry(item.activity))" 2962 + > 2963 + <span class="chat-activity-dot" aria-hidden="true"></span> 2964 + <div class="chat-activity-copy"> 2965 + <div class="chat-activity-line"> 2966 + <span class="chat-activity-kind">{{ activityKindLabel(activityCurrentEntry(item.activity), t) }}</span> 2967 + <span class="chat-activity-name">{{ activityEntryTitle(activityCurrentEntry(item.activity)) }}</span> 2968 + </div> 2969 + <div v-if="activityParams(activityCurrentEntry(item.activity)).length > 0" class="chat-activity-params"> 2970 + <span 2971 + v-for="(param, paramIndex) in activityParams(activityCurrentEntry(item.activity))" 2972 + :key="item.id + ':activity:param:' + paramIndex" 2973 + class="chat-activity-param" 2974 + > 2975 + <span class="chat-activity-param-key">{{ param.key }}</span> 2976 + <span class="chat-activity-param-value">{{ param.value }}</span> 2977 + </span> 2978 + </div> 2979 + <p v-if="activityEntryNote(activityCurrentEntry(item.activity))" class="chat-activity-note"> 2980 + {{ activityEntryNote(activityCurrentEntry(item.activity)) }} 2981 + </p> 2982 + </div> 2983 + </div> 2984 + <div v-if="activityHistoryCount(item.activity) > 0" class="chat-activity-history"> 2985 + <button 2986 + type="button" 2987 + class="chat-activity-toggle" 2988 + @click="toggleActivityExpanded(item.id)" 2989 + > 2990 + {{ activityHistoryToggleLabel(item.activity, activityExpanded(item.id), t) }} 2991 + </button> 2992 + <ol v-if="activityExpanded(item.id)" class="chat-activity-list"> 2993 + <li 2994 + v-for="(entry, historyIndex) in activityHistoryEntries(item.activity)" 2995 + :key="item.id + ':activity:history:' + historyIndex" 2996 + :class="activityEntryClass(entry)" 2997 + > 2998 + <span class="chat-activity-dot" aria-hidden="true"></span> 2999 + <div class="chat-activity-copy"> 3000 + <div class="chat-activity-line"> 3001 + <span class="chat-activity-kind">{{ activityKindLabel(entry, t) }}</span> 3002 + <span class="chat-activity-name">{{ activityEntryTitle(entry) }}</span> 3003 + <span class="chat-activity-history-status">{{ activityStatusLabel(entry, t) }}</span> 3004 + </div> 3005 + <div v-if="activityParams(entry).length > 0" class="chat-activity-params"> 3006 + <span 3007 + v-for="(param, paramIndex) in activityParams(entry)" 3008 + :key="item.id + ':activity:history:param:' + historyIndex + ':' + paramIndex" 3009 + class="chat-activity-param" 3010 + > 3011 + <span class="chat-activity-param-key">{{ param.key }}</span> 3012 + <span class="chat-activity-param-value">{{ param.value }}</span> 3013 + </span> 3014 + </div> 3015 + <p v-if="activityEntryNote(entry)" class="chat-activity-note">{{ activityEntryNote(entry) }}</p> 3016 + </div> 3017 + </li> 3018 + </ol> 3019 + </div> 3020 + </section> 3021 + <div v-if="showHistoryAgentBubble(item)" :class="historySurfaceClass(item)"> 3022 + <div v-if="showHistorySkeleton(item)" class="chat-history-skeleton" aria-hidden="true"> 3023 + <QSkeleton variant="text" width="92%" /> 3024 + <QSkeleton variant="text" width="100%" /> 3025 + <QSkeleton variant="text" width="68%" /> 3026 + </div> 3027 + <MarkdownContent 3028 + :class="showHistorySkeleton(item) ? 'chat-history-markdown is-render-pending' : 'chat-history-markdown'" 3029 + :source="item.text" 3030 + format="auto" 3031 + theme="blueprint" 3032 + @rendered="markHistoryItemRendered(item.id)" 3033 + /> 2594 3034 </div> 2595 - <MarkdownContent 2596 - :class="showHistorySkeleton(item) ? 'chat-history-markdown is-render-pending' : 'chat-history-markdown'" 2597 - :source="item.text" 2598 - format="auto" 2599 - theme="blueprint" 2600 - @rendered="markHistoryItemRendered(item.id)" 2601 - /> 2602 - </template> 2603 - <div v-else class="chat-history-body">{{ item.text }}</div> 3035 + </div> 3036 + </template> 3037 + <div v-else :class="historySurfaceClass(item)"> 3038 + <div class="chat-history-body">{{ item.text }}</div> 2604 3039 </div> 2605 3040 </article> 2606 3041 <p v-if="chatHistoryItems.length === 0 && !historyLoading" class="muted">{{ t("chat_empty") }}</p>