v0.20.0: thinking blocks, chat windows overhaul, scroll fix
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Backend:
- Add isThinking flag to agentEvent for text before tool calls
- Separate thinking from response text in runChatTurn and continue.go
- Persist thinking in a dedicated field in message content

Frontend:
- Add thinking field to MessageContent, ChatMessage, ChatTextEvent types
- Create ThinkingBlock.svelte — collapsible block with brain icon
- SSE handler moves text_delta content to thinking on isThinking flag
- Render thinking block between tools and response in ChatThread
- Fix chat window scroll reset on focus change (stable windowKeys order)
- Remove redundant #key id wrapper in WindowLayer
- Enlarge sidebar rail (24→32 default, 40→60 max)
- Remove glyph from sidebar, square graph at top
- Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
This commit is contained in:
2026-08-04 22:42:53 +02:00
parent 20adb89650
commit 1aaedf498a
25 changed files with 1852 additions and 1211 deletions

View File

@@ -181,6 +181,11 @@ type agentEvent struct {
Data any `json:"data,omitempty"`
SessionID string `json:"session_id,omitempty"`
Iteration int `json:"iteration,omitempty"`
// IsThinking marks text/text_delta events that carry the model's internal
// reasoning (text produced before tool calls in the same iteration), as
// distinct from the final response text. The frontend renders these as
// collapsible thinking blocks separated from the response.
IsThinking bool `json:"is_thinking,omitempty"`
}
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
@@ -465,7 +470,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// led to each step. Emitting it lets the persist layer accumulate
// per-iteration reasoning into the row's text field.
if strings.TrimSpace(msg.Content) != "" {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true})
}
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)

View File

@@ -242,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
var toolCalls []map[string]any
var finalText, errText string
var finalThinking string
persist := func() {
if msgID == uuid.Nil {
@@ -254,6 +255,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": text,
"thinking": finalThinking,
"tool_calls": toolCalls,
"auto": true, // marks this as an autonomous continuation, not an operator turn
})
@@ -289,10 +291,14 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
}
}
toolCalls, finalText, errText = nil, "", ""
finalThinking = ""
// P3: accumulate per-iteration reasoning instead of overwriting
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
var textParts []string
var thinkingParts []string
emit := func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
@@ -319,8 +325,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}

View File

@@ -180,7 +180,9 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
// P3: accumulate per-iteration reasoning instead of overwriting with the
// final `text` event (see the original inline comment in handleChat).
var textParts []string
var thinkingParts []string
var finalText string
var finalThinking string
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
@@ -194,6 +196,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"thinking": finalThinking,
"tool_calls": toolCalls,
})
a.store.updateMessage(pctx, msgID, body)
@@ -223,8 +226,13 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}