diff --git a/.gitignore b/.gitignore
index 543a364..5e2bb67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
backend/sproutclaw-web
backend/sproutclaw-web.exe
+backend/dist/
backend/data/
frontend/dist/
frontend/dist-desktop/
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 96af49d..47458ad 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -27,16 +27,16 @@ func main() {
defer database.Close()
log.Printf("[sproutclaw-web] db: %s", database.DbPath)
- // pi CLI RPC client
+ // SproutClaw CLI RPC client
piClient, err := rpc.NewClient(cfg.PiCmd, cfg.PiArgs, cfg.RepoRoot, cfg.AgentDir, func(code int) {
if code != 0 {
- log.Printf("[sproutclaw-web] pi CLI exited with code %d, shutting down", code)
+ log.Printf("[sproutclaw-web] SproutClaw CLI exited with code %d, shutting down", code)
os.Exit(1)
}
os.Exit(0)
})
if err != nil {
- log.Fatalf("pi client: %v", err)
+ log.Fatalf("SproutClaw client: %v", err)
}
defer piClient.Stop()
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index b535b63..7751345 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -8,12 +8,14 @@ import (
"strings"
)
+const ConfigDirName = ".sproutclaw"
+
type Config struct {
Port int
AgentDir string
- RepoRoot string // pi 仓库根目录(pi CLI 子进程的工作目录)
+ RepoRoot string // SproutClaw 仓库根目录(CLI 子进程的工作目录)
DataDir string // sproutclaw-web 自己的数据目录(SQLite 等)
- PiCmd string // pi CLI 启动命令(完整路径)
+ PiCmd string // SproutClaw CLI 启动命令(完整路径)
PiArgs []string
// 派生路径(从 AgentDir 推导)
@@ -35,13 +37,16 @@ func New() *Config {
agentDir := flag.String("agent-dir", "", "SproutClaw agent directory (e.g. /path/to/.sproutclaw/agent)")
repoRoot := flag.String("repo-root", "", "SproutClaw repo root (CLI cwd); defaults to two levels above agent-dir")
dataDir := flag.String("data-dir", "", "sproutclaw-web data dir for SQLite (defaults to ./data)")
- piCmd := flag.String("pi-cmd", "", "pi CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
+ sproutclawCmd := flag.String("sproutclaw-cmd", "", "SproutClaw CLI command (alias for --pi-cmd)")
+ piCmd := flag.String("pi-cmd", "", "SproutClaw CLI command (may include args, e.g. \"node /path/tsx.mjs /path/cli.ts\")")
frontendDist := flag.String("frontend-dist", "", "path to frontend/dist (defaults to ../frontend/dist relative to binary)")
flag.Parse()
if *agentDir == "" {
- cwd, _ := os.Getwd()
- *agentDir = filepath.Join(cwd, ".sproutclaw", "agent")
+ *agentDir = resolveDefaultAgentDir()
+ }
+ if abs, err := filepath.Abs(*agentDir); err == nil {
+ *agentDir = abs
}
cfg := &Config{
@@ -50,9 +55,11 @@ func New() *Config {
RepoRoot: *repoRoot,
DataDir: *dataDir,
}
- // repoRoot defaults to the directory two levels above agentDir (.sproutclaw/agent -> repo root)
if cfg.RepoRoot == "" {
- cfg.RepoRoot = filepath.Dir(filepath.Dir(*agentDir))
+ cfg.RepoRoot = inferRepoRoot(*agentDir)
+ }
+ if abs, err := filepath.Abs(cfg.RepoRoot); err == nil {
+ cfg.RepoRoot = abs
}
// dataDir defaults to ./data relative to cwd (run-backend.bat cd's into backend/)
if cfg.DataDir == "" {
@@ -62,10 +69,14 @@ func New() *Config {
if abs, err := filepath.Abs(cfg.DataDir); err == nil {
cfg.DataDir = abs
}
- // --pi-cmd may include arguments separated by spaces, e.g.
- // "node /path/to/tsx/dist/cli.mjs /path/to/pi/cli.ts"
+ // --pi-cmd / --sproutclaw-cmd may include arguments separated by spaces, e.g.
+ // "node /path/to/tsx/dist/cli.mjs /path/to/cli.ts"
// Split into executable + args so exec.Command receives them correctly.
- if parts := strings.Fields(*piCmd); len(parts) > 0 {
+ cmdLine := strings.TrimSpace(*sproutclawCmd)
+ if cmdLine == "" {
+ cmdLine = strings.TrimSpace(*piCmd)
+ }
+ if parts := strings.Fields(cmdLine); len(parts) > 0 {
cfg.PiCmd = parts[0]
cfg.PiArgs = parts[1:]
}
@@ -107,3 +118,32 @@ func Platform() string {
func Arch() string {
return runtime.GOARCH
}
+
+func resolveDefaultAgentDir() string {
+ if env := strings.TrimSpace(os.Getenv("SPROUTCLAW_AGENT_DIR")); env != "" {
+ return env
+ }
+ cwd, _ := os.Getwd()
+ candidates := []string{
+ filepath.Join(cwd, ConfigDirName, "agent"),
+ filepath.Join(cwd, "..", "sproutclaw", ConfigDirName, "agent"),
+ filepath.Join(cwd, "..", "sproutclaw", ".pi", "agent"),
+ }
+ for _, candidate := range candidates {
+ if st, err := os.Stat(candidate); err == nil && st.IsDir() {
+ return candidate
+ }
+ }
+ return filepath.Join(cwd, ConfigDirName, "agent")
+}
+
+func inferRepoRoot(agentDir string) string {
+ agentDir = filepath.Clean(agentDir)
+ if filepath.Base(agentDir) == "agent" {
+ configDir := filepath.Base(filepath.Dir(agentDir))
+ if configDir == ConfigDirName || configDir == ".pi" {
+ return filepath.Dir(filepath.Dir(agentDir))
+ }
+ }
+ return filepath.Dir(filepath.Dir(agentDir))
+}
diff --git a/backend/internal/handlers/settings.go b/backend/internal/handlers/settings.go
index 6006d94..12fda59 100644
--- a/backend/internal/handlers/settings.go
+++ b/backend/internal/handlers/settings.go
@@ -225,25 +225,26 @@ func handleEnvironment(cfg *config.Config) gin.HandlerFunc {
}
c.JSON(http.StatusOK, gin.H{
- "goVersion": runtime.Version(),
- "platform": runtime.GOOS,
- "arch": runtime.GOARCH,
- "osRelease": osRelease,
- "hostname": hostname,
- "numCPU": runtime.NumCPU(),
- "totalMemMb": totalMemMb,
- "freeMemMb": freeMemMb,
- "pid": os.Getpid(),
- "uptime": uptimeSec,
- "execPath": exe,
- "goroutines": runtime.NumGoroutine(),
- "goHeapMb": goHeapMb,
- "cwd": cwd,
- "port": cfg.Port,
- "agentDir": cfg.AgentDir,
- "dataDir": cfg.DataDir,
- "repoRoot": cfg.RepoRoot,
- "piCmd": piCmd,
+ "goVersion": runtime.Version(),
+ "platform": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "osRelease": osRelease,
+ "hostname": hostname,
+ "numCPU": runtime.NumCPU(),
+ "totalMemMb": totalMemMb,
+ "freeMemMb": freeMemMb,
+ "pid": os.Getpid(),
+ "uptime": uptimeSec,
+ "execPath": exe,
+ "goroutines": runtime.NumGoroutine(),
+ "goHeapMb": goHeapMb,
+ "cwd": cwd,
+ "port": cfg.Port,
+ "agentDir": cfg.AgentDir,
+ "dataDir": cfg.DataDir,
+ "repoRoot": cfg.RepoRoot,
+ "piCmd": piCmd,
+ "sproutclawCmd": piCmd,
"memUsagePct": func() string {
if totalMemMb == 0 {
return ""
diff --git a/backend/internal/handlers/terminal.go b/backend/internal/handlers/terminal.go
index f9a8769..e8eef81 100644
--- a/backend/internal/handlers/terminal.go
+++ b/backend/internal/handlers/terminal.go
@@ -37,7 +37,7 @@ func handleTerminalWS(cfg *config.Config) gin.HandlerFunc {
}
defer conn.Close()
- if err := services.ServeWebTerminal(conn, cfg.RepoRoot); err != nil {
+ if err := services.ServeWebTerminal(conn, cfg.RepoRoot, cfg.AgentDir); err != nil {
_ = conn.WriteJSON(gin.H{"type": "error", "error": err.Error()})
}
}
diff --git a/backend/internal/rpc/client.go b/backend/internal/rpc/client.go
index affb88a..e931616 100644
--- a/backend/internal/rpc/client.go
+++ b/backend/internal/rpc/client.go
@@ -73,16 +73,19 @@ type Client struct {
// with the exit code when the subprocess terminates.
func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit func(int)) (*Client, error) {
if piCmd == "" {
- return nil, fmt.Errorf("pi CLI command not specified (use --pi-cmd)")
+ return nil, fmt.Errorf("SproutClaw CLI command not specified (use --pi-cmd or --sproutclaw-cmd)")
}
- // pi enters RPC mode via "--mode rpc"
+ // SproutClaw enters RPC mode via "--mode rpc"
args := append(append([]string{}, piArgs...), "--mode", "rpc")
cmd := exec.Command(piCmd, args...)
cmd.Dir = repoRoot
- cmd.Env = append(os.Environ(), fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir))
+ cmd.Env = append(os.Environ(),
+ fmt.Sprintf("SPROUTCLAW_CODING_AGENT_DIR=%s", agentDir),
+ fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir),
+ )
- log.Printf("[sproutclaw-web] launching pi RPC: %s %s (cwd=%s)", piCmd, strings.Join(args, " "), repoRoot)
+ log.Printf("[sproutclaw-web] launching SproutClaw RPC: %s %s (cwd=%s agent-dir=%s)", piCmd, strings.Join(args, " "), repoRoot, agentDir)
stdin, err := cmd.StdinPipe()
if err != nil {
@@ -115,7 +118,7 @@ func NewClient(piCmd string, piArgs []string, repoRoot, agentDir string, onExit
if cmd.ProcessState != nil {
code = cmd.ProcessState.ExitCode()
}
- log.Printf("[sproutclaw-web] pi RPC exited, code=%d", code)
+ log.Printf("[sproutclaw-web] SproutClaw RPC exited, code=%d", code)
onExit(code)
}()
diff --git a/backend/internal/services/web_terminal.go b/backend/internal/services/web_terminal.go
index 632c0ee..8f7474f 100644
--- a/backend/internal/services/web_terminal.go
+++ b/backend/internal/services/web_terminal.go
@@ -21,7 +21,7 @@ type terminalWSMessage struct {
}
// ServeWebTerminal attaches a PTY shell session to the websocket connection.
-func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
+func ServeWebTerminal(conn *websocket.Conn, workDir, agentDir string) error {
abs, err := filepath.Abs(workDir)
if err != nil {
return fmt.Errorf("解析目录失败: %w", err)
@@ -34,15 +34,21 @@ func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
return fmt.Errorf("不是有效目录: %s", abs)
}
+ envInject := map[string]string{
+ "TERM": "xterm-256color",
+ "COLORTERM": "truecolor",
+ }
+ if agentDir != "" {
+ envInject["SPROUTCLAW_CODING_AGENT_DIR"] = agentDir
+ envInject["PI_CODING_AGENT_DIR"] = agentDir
+ }
+
ptmx, err := crosspty.Start(crosspty.CommandConfig{
- Argv: defaultShellArgv(),
- Dir: abs,
- Env: os.Environ(),
- EnvInject: map[string]string{
- "TERM": "xterm-256color",
- "COLORTERM": "truecolor",
- },
- Size: crosspty.TermSize{Rows: 24, Cols: 80},
+ Argv: defaultShellArgv(),
+ Dir: abs,
+ Env: os.Environ(),
+ EnvInject: envInject,
+ Size: crosspty.TermSize{Rows: 24, Cols: 80},
})
if err != nil {
if err == crosspty.ErrConPTYNotSupported {
diff --git a/build.sh b/build.sh
index 47be694..2e8e137 100755
--- a/build.sh
+++ b/build.sh
@@ -6,6 +6,6 @@ OUT="$SCRIPT_DIR/backend/dist/sproutclaw-web-backend"
echo "[build] compiling Go backend -> $OUT"
cd "$SCRIPT_DIR/backend"
-/usr/local/go/bin/go build -o "$OUT" ./cmd/server
+go build -o "$OUT" ./cmd/server
chmod +x "$OUT"
echo "[build] done: $OUT"
diff --git a/frontend/src/components/chat/AssistantTurnBody.module.css b/frontend/src/components/chat/AssistantTurnBody.module.css
index 4779a5e..c646bdf 100644
--- a/frontend/src/components/chat/AssistantTurnBody.module.css
+++ b/frontend/src/components/chat/AssistantTurnBody.module.css
@@ -1,35 +1,31 @@
.turn {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 3px;
}
-.text {
- line-height: 1.5;
+.textSegment {
+ composes: textSegment from "./agentSegment.module.css";
}
-.thinking {
- line-height: 1.45;
- font-style: italic;
- color: #6b7280;
+.textBody {
+ composes: textBody from "./agentSegment.module.css";
+}
+
+.text:global(.markdown-body) {
+ font-size: 14px;
+ line-height: 1.4;
+ background: transparent;
+}
+
+.streamingPlain {
+ composes: streamingPlain from "./agentSegment.module.css";
}
.thinkingHidden {
margin: 0;
font-style: italic;
color: #9ca3af;
- font-size: 0.92em;
-}
-
-.streaming::after {
- content: "▊";
- animation: blink 0.8s step-end infinite;
- color: #8b6fd4;
- margin-left: 1px;
-}
-
-@keyframes blink {
- 50% {
- opacity: 0;
- }
-}
+ font-size: 12px;
+ line-height: 1.4;
+}
\ No newline at end of file
diff --git a/frontend/src/components/chat/AssistantTurnBody.tsx b/frontend/src/components/chat/AssistantTurnBody.tsx
index 1de5745..b930c6d 100644
--- a/frontend/src/components/chat/AssistantTurnBody.tsx
+++ b/frontend/src/components/chat/AssistantTurnBody.tsx
@@ -1,5 +1,7 @@
+import { memo, useMemo } from "react";
import type { AssistantBlock } from "../../types/message";
import { renderMarkdown } from "../../utils/markdown";
+import { ThinkingBlock } from "./ThinkingBlock";
import styles from "./AssistantTurnBody.module.css";
interface AssistantTurnBodyProps {
@@ -8,7 +10,30 @@ interface AssistantTurnBodyProps {
showThinking?: boolean;
}
-export function AssistantTurnBody({
+interface TextBlockContentProps {
+ content: string;
+ streaming: boolean;
+}
+
+const TextBlockContent = memo(function TextBlockContent({ content, streaming }: TextBlockContentProps) {
+ const html = useMemo(() => (streaming ? "" : renderMarkdown(content)), [content, streaming]);
+
+ return (
+
+ {streaming ? (
+
{content}
+ ) : (
+
+ )}
+
+ );
+});
+
+export const AssistantTurnBody = memo(function AssistantTurnBody({
blocks,
streaming = false,
showThinking = true,
@@ -27,22 +52,22 @@ export function AssistantTurnBody({
);
}
return (
-
);
}
return (
-
);
})}
);
-}
+});
\ No newline at end of file
diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx
index f8865b2..7d47c6a 100644
--- a/frontend/src/components/chat/MessageBubble.tsx
+++ b/frontend/src/components/chat/MessageBubble.tsx
@@ -1,7 +1,6 @@
-import { useState } from "react";
+import { memo, useState } from "react";
import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext";
-import { useChatContext } from "../../context/ChatContext";
import {
downloadTextFile,
messageMarkdownFilename,
@@ -20,6 +19,7 @@ interface MessageBubbleProps {
message: ChatMessage;
agentGutter?: "avatar" | "spacer" | "none";
toolsExpanded?: boolean;
+ showThinkingAndTools?: boolean;
}
function ChatAvatar({
@@ -102,13 +102,13 @@ function AssistantActions({ content }: { content: string }) {
);
}
-export function MessageBubble({
+export const MessageBubble = memo(function MessageBubble({
message,
agentGutter = "none",
toolsExpanded = false,
+ showThinkingAndTools = true,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
- const { showThinkingAndTools } = useChatContext();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
@@ -116,7 +116,7 @@ export function MessageBubble({
if (message.role === "tool_call") {
return (
-
+
{gutter}
@@ -211,4 +211,4 @@ export function MessageBubble({
}
return
{message.content}
;
-}
+});
\ No newline at end of file
diff --git a/frontend/src/components/chat/MessageList.module.css b/frontend/src/components/chat/MessageList.module.css
index 7e42cf2..c8f11ad 100644
--- a/frontend/src/components/chat/MessageList.module.css
+++ b/frontend/src/components/chat/MessageList.module.css
@@ -1,10 +1,10 @@
.chat {
flex: 1;
overflow-y: auto;
- padding: 12px 16px 10px;
+ padding: 8px 12px 6px;
display: flex;
flex-direction: column;
- gap: 6px;
+ gap: 3px;
min-height: 0;
}
@@ -50,9 +50,9 @@
}
.msg {
- padding: 10px 12px;
+ padding: 8px 10px;
line-height: 1.4;
- font-size: 16px;
+ font-size: 14px;
word-wrap: break-word;
white-space: pre-wrap;
}
@@ -110,10 +110,10 @@
align-self: flex-start;
display: flex;
align-items: flex-start;
- gap: 10px;
+ gap: 8px;
width: 100%;
max-width: 100%;
- margin: 0 0 2px;
+ margin: 0;
}
.assistantAvatar {
@@ -139,22 +139,25 @@
flex: 1;
min-width: 0;
max-width: 100%;
- padding: 10px 12px;
- background: #fff;
- border: 1px solid #e8ecf0;
- border-radius: 14px 14px 14px 4px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
color: #1a1a1a;
- line-height: 1.46;
+ line-height: 1.4;
white-space: normal;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
}
.assistantToolbar {
display: flex;
justify-content: flex-end;
gap: 4px;
- margin: -2px -2px 6px 0;
- min-height: 22px;
+ margin: 0 0 1px;
+ min-height: 20px;
opacity: 0;
transition: opacity 0.15s ease;
}
@@ -192,6 +195,9 @@
.assistantBody {
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
}
.system {
@@ -211,6 +217,35 @@
background: transparent;
border: none;
box-shadow: none;
+ font-size: 14px;
+ line-height: 1.4;
+ white-space: normal;
+}
+
+.toolCallRow {
+ margin: 0;
+}
+
+.toolCallRow + .toolCallRow {
+ margin-top: -3px;
+}
+
+.toolCallRow:has(+ .toolCallRow) :global(.tool-call-block) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ box-shadow: none;
+}
+
+.toolCallRow + .toolCallRow :global(.tool-call-block) {
+ border-top: none;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ box-shadow: none;
+}
+
+.assistantRow:not(.toolCallRow) + .toolCallRow,
+.toolCallRow + .assistantRow:not(.toolCallRow) {
+ margin-top: -2px;
}
.thinking {
@@ -250,129 +285,12 @@
margin-left: 1px;
}
-.assistantRow + .assistantRow {
- margin-top: 2px;
+.assistantRow + .assistantRow:not(.toolCallRow) {
+ margin-top: 0;
}
-.assistant :global(h1),
-.assistant :global(h2),
-.assistant :global(h3),
-.assistant :global(h4),
-.assistant :global(h5),
-.assistant :global(h6) {
- margin: 0.78em 0 0.3em;
- font-weight: 600;
- line-height: 1.25;
-}
-
-.assistant :global(h1) { font-size: 1.18em; }
-.assistant :global(h2) { font-size: 1.1em; }
-.assistant :global(h3) { font-size: 1.03em; }
-.assistant :global(h4) { font-size: 1em; }
-
-.assistant > :global(:first-child) { margin-top: 0; }
-.assistant > :global(:last-child) { margin-bottom: 0; }
-
-.assistant :global(p) {
- margin: 0 0 0.46em;
-}
-
-.assistant :global(p + p) {
- margin-top: 0.3em;
-}
-
-.assistant :global(strong) { font-weight: 600; }
-.assistant :global(em) { font-style: italic; }
-
-.assistant :global(a) {
- color: #2563eb;
- text-decoration: none;
-}
-
-.assistant :global(a:hover) { text-decoration: underline; }
-
-.assistant :global(ul),
-.assistant :global(ol) {
- margin: 0.32em 0 0.48em;
- padding-left: 1.18em;
-}
-
-.assistant :global(li) {
- margin: 0.08em 0;
- padding-left: 0.04em;
-}
-
-.assistant :global(li > p) {
- margin: 0;
-}
-
-.assistant :global(blockquote) {
- margin: 0.36em 0;
- padding: 3px 9px;
- border-left: 2px solid #e5e5e5;
- color: #666;
-}
-
-.assistant :global(hr) {
- border: none;
- border-top: 1px solid #eee;
- margin: 0.58em 0;
-}
-
-.assistant :global(table) {
- border-collapse: collapse;
- margin: 0.42em 0;
- font-size: 13px;
- width: 100%;
-}
-
-.assistant :global(th),
-.assistant :global(td) {
- border: 1px solid #e5e5e5;
- padding: 4px 7px;
- text-align: left;
-}
-
-.assistant :global(th) {
- background: #fafafa;
- font-weight: 600;
-}
-
-.assistant :global(code:not(.hljs)) {
- font-size: 0.9em;
- background: #f5f5f5;
- padding: 1px 5px;
- border-radius: 4px;
- color: #d63384;
- word-break: break-word;
-}
-
-.assistant :global(pre.assistant-pre) {
- margin: 0.5em 0 0.6em;
- padding: 0;
- background: transparent;
- border: 1px solid #e8edf2;
- border-radius: 8px;
- overflow-x: auto;
- line-height: 1.48;
-}
-
-.assistant :global(pre.assistant-pre code.hljs) {
- display: block;
- padding: 10px 12px;
- background: none;
- border: none;
- color: inherit;
- font-size: 13px;
- line-height: 1.48;
- word-break: normal;
- tab-size: 2;
-}
-
-.assistant :global(img) {
- max-width: 100%;
- border-radius: 8px;
- margin: 0.42em 0;
+.assistant :global(.markdown-body) {
+ min-width: 0;
}
@media (min-width: 769px) {
@@ -384,12 +302,13 @@
@media (max-width: 768px) {
.chat {
- padding: 10px 12px 8px;
+ padding: 6px 10px 4px;
+ gap: 3px;
}
.msg {
- padding: 11px 12px;
- font-size: 16px;
+ padding: 8px 10px;
+ font-size: 14px;
}
.userRow {
diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx
index ca7553e..9999758 100644
--- a/frontend/src/components/chat/MessageList.tsx
+++ b/frontend/src/components/chat/MessageList.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { useChatContext } from "../../context/ChatContext";
import type { MessageRole } from "../../types/message";
import { MessageBubble } from "./MessageBubble";
@@ -20,13 +20,22 @@ function isAgentSideRole(role: MessageRole): boolean {
export function MessageList() {
const { messages, toolsExpanded, showThinkingAndTools } = useChatContext();
- const visibleMessages = showThinkingAndTools
- ? messages
- : messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call");
+ const visibleMessages = useMemo(
+ () =>
+ showThinkingAndTools
+ ? messages
+ : messages.filter((m) => m.role !== "thinking" && m.role !== "tool_call"),
+ [messages, showThinkingAndTools],
+ );
const chatRef = useRef
(null);
const stickToBottomRef = useRef(true);
const prevMessageCountRef = useRef(0);
+ const lastMessage = messages[messages.length - 1];
+ const scrollKey = lastMessage
+ ? `${messages.length}:${lastMessage.id}:${lastMessage.content.length}:${lastMessage.streaming ? 1 : 0}`
+ : String(messages.length);
+
useEffect(() => {
const el = chatRef.current;
if (!el) return;
@@ -47,8 +56,8 @@ export function MessageList() {
const messageCount = messages.length;
prevMessageCountRef.current = messageCount;
- const lastMessage = messages[messageCount - 1];
- const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
+ const last = messages[messageCount - 1];
+ const userJustSent = messageCount > prevCount && last?.role === "user";
if (userJustSent || prevCount === 0) {
stickToBottomRef.current = true;
@@ -59,7 +68,7 @@ export function MessageList() {
requestAnimationFrame(() => {
if (chatRef.current) scrollToBottom(chatRef.current);
});
- }, [messages]);
+ }, [scrollKey, messages.length]);
if (messages.length === 0) {
return (
@@ -91,9 +100,10 @@ export function MessageList() {
message={msg}
agentGutter={agentGutter}
toolsExpanded={toolsExpanded}
+ showThinkingAndTools={showThinkingAndTools}
/>
);
})}
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/chat/ThinkingBlock.module.css b/frontend/src/components/chat/ThinkingBlock.module.css
index 6a48cad..a847d02 100644
--- a/frontend/src/components/chat/ThinkingBlock.module.css
+++ b/frontend/src/components/chat/ThinkingBlock.module.css
@@ -1,69 +1,28 @@
.block {
- margin: 0;
+ composes: segment from "./agentSegment.module.css";
}
.summary {
- list-style: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- user-select: none;
- font-weight: 500;
- color: #7c6a9a;
-}
-
-.summary::-webkit-details-marker {
- display: none;
-}
-
-.summary::before {
- content: "";
- width: 0;
- height: 0;
- border-left: 5px solid #a894c6;
- border-top: 3.5px solid transparent;
- border-bottom: 3.5px solid transparent;
- flex-shrink: 0;
- transform: rotate(0deg);
- transition: transform 0.15s ease;
-}
-
-.block[open] > .summary::before {
- transform: rotate(90deg);
+ composes: summary from "./agentSegment.module.css";
}
.summaryLabel {
- flex-shrink: 0;
- font-size: inherit;
- letter-spacing: 0.02em;
+ composes: summaryTitle from "./agentSegment.module.css";
}
.summaryText {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ composes: summaryPreview from "./agentSegment.module.css";
font-style: italic;
- color: #8b7aa8;
}
.detail {
- margin: 0;
- padding: 6px 10px 8px;
- border-top: 1px solid #ece6f5;
+ composes: detail from "./agentSegment.module.css";
}
.body {
- margin: 0;
- white-space: pre-wrap;
- word-break: break-word;
- font-size: inherit;
- line-height: inherit;
+ composes: body from "./agentSegment.module.css";
font-style: italic;
- color: #6f5f8d;
+ color: #6b7280;
}
.streaming::after {
@@ -77,4 +36,4 @@
50% {
opacity: 0;
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/chat/ThinkingBlock.tsx b/frontend/src/components/chat/ThinkingBlock.tsx
index 31bcf4c..89180ac 100644
--- a/frontend/src/components/chat/ThinkingBlock.tsx
+++ b/frontend/src/components/chat/ThinkingBlock.tsx
@@ -10,7 +10,7 @@ export function ThinkingBlock({ content, streaming }: ThinkingBlockProps) {
const summary = deriveThinkingSummary(content);
return (
-
+
思考
{summary}
diff --git a/frontend/src/components/chat/ToolCallBlock.module.css b/frontend/src/components/chat/ToolCallBlock.module.css
index 068af1b..6b4421a 100644
--- a/frontend/src/components/chat/ToolCallBlock.module.css
+++ b/frontend/src/components/chat/ToolCallBlock.module.css
@@ -1,97 +1,138 @@
.block {
- margin: 0;
- border-radius: 8px;
- border: 1px solid #e5e7eb;
- background: #f8fafc;
- overflow: hidden;
-}
-
-.pending {
- border-color: #dbeafe;
- background: #f0f7ff;
-}
-
-.success {
- border-color: #bbf7d0;
- background: #f0fdf4;
-}
-
-.error {
- border-color: #fecaca;
- background: #fef2f2;
+ composes: segment from "./agentSegment.module.css";
}
.summary {
- list-style: none;
- cursor: pointer;
- display: flex;
- align-items: center;
+ composes: summary from "./agentSegment.module.css";
justify-content: space-between;
- gap: 8px;
- padding: 8px 10px;
- user-select: none;
-}
-
-.summary::-webkit-details-marker {
- display: none;
}
.toolName {
- font-weight: 600;
- color: #374151;
- font-size: 0.92em;
+ composes: summaryTitleGrow from "./agentSegment.module.css";
}
.status {
flex-shrink: 0;
- font-size: 11px;
- color: #6b7280;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.2;
+ padding: 1px 5px;
+ border-radius: 999px;
+ background: #f6f8fa;
+ color: #656d76;
}
.pending .status {
- color: #2563eb;
+ color: #0969da;
+ background: #ddf4ff;
+}
+
+.pending .status::after {
+ content: "";
+ display: inline-block;
+ width: 4px;
+ height: 4px;
+ margin-left: 3px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: pulse 1.2s ease-in-out infinite;
+ vertical-align: middle;
}
.success .status {
- color: #15803d;
+ color: #1a7f37;
+ background: #dafbe1;
}
.error .status {
- color: #b91c1c;
+ color: #cf222e;
+ background: #ffebe9;
}
.detail {
- margin: 0;
- padding: 8px 10px 10px;
- border-top: 1px solid rgba(0, 0, 0, 0.06);
+ composes: detail from "./agentSegment.module.css";
}
.args {
- margin: 0 0 6px;
- font-size: 0.85em;
- color: #6b7280;
- font-family: var(--font-mono, ui-monospace, monospace);
+ margin: 4px 0 0;
+ padding: 4px 6px;
+ font-size: 11px;
+ color: #656d76;
+ background: #f6f8fa;
+ border: 1px solid #e8ecf0;
+ border-radius: 5px;
+ font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace);
word-break: break-all;
+ line-height: 1.35;
}
-.detailPre {
- margin: 0;
- padding: 0;
+.markdownBody:global(.markdown-body) {
+ font-size: 13px;
+ line-height: 1.4;
+ background: transparent;
+ padding: 4px 0 0;
+}
+
+.markdownBody:global(.markdown-body) :global(p),
+.markdownBody:global(.markdown-body) :global(ul),
+.markdownBody:global(.markdown-body) :global(ol),
+.markdownBody:global(.markdown-body) :global(blockquote),
+.markdownBody:global(.markdown-body) :global(table),
+.markdownBody:global(.markdown-body) :global(pre) {
+ margin-top: 0;
+ margin-bottom: 0.4em;
+}
+
+.markdownBody:global(.markdown-body) :global(p:last-child),
+.markdownBody:global(.markdown-body) :global(pre:last-child) {
+ margin-bottom: 0;
+}
+
+.markdownBody:global(.markdown-body) :global(pre) {
+ padding: 6px 8px;
+ font-size: 11px;
+}
+
+.diffPre,
+.plainPre {
+ margin: 4px 0 0;
+ padding: 6px 8px;
white-space: pre-wrap;
word-break: break-word;
- font-size: inherit;
- line-height: inherit;
- font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 11px;
+ line-height: 1.35;
+ font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace);
+ background: #f6f8fa;
+ border: 1px solid #e8ecf0;
+ border-radius: 5px;
+ overflow-x: auto;
}
-.detailPre :global(.diffAdd) {
- color: #15803d;
+.diffPre :global(.diffAdd) {
+ color: #1a7f37;
}
-.detailPre :global(.diffDel) {
- color: #b91c1c;
+.diffPre :global(.diffDel) {
+ color: #cf222e;
}
-.detailPre :global(.diffMeta) {
- color: #6b7280;
+.diffPre :global(.diffMeta) {
+ color: #656d76;
}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.35;
+ }
+}
+
+@media (max-width: 768px) {
+ .detail {
+ max-height: min(180px, 28vh);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/chat/ToolCallBlock.tsx b/frontend/src/components/chat/ToolCallBlock.tsx
index 87c49be..29de99a 100644
--- a/frontend/src/components/chat/ToolCallBlock.tsx
+++ b/frontend/src/components/chat/ToolCallBlock.tsx
@@ -1,4 +1,6 @@
+import { memo, useMemo } from "react";
import { deriveToolCallSummary, formatToolBodyHtml } from "../../utils/toolCall";
+import { renderMarkdown } from "../../utils/markdown";
import styles from "./ToolCallBlock.module.css";
interface ToolCallBlockProps {
@@ -31,34 +33,66 @@ function parseToolCallContent(content: string): {
return { title, body, status };
}
-export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
- const { title, body, status } = parseToolCallContent(content);
+interface ToolBodyProps {
+ body: string;
+ fallback: string;
+ hasDiff: boolean;
+ status: "pending" | "success" | "error";
+}
+
+const ToolBody = memo(function ToolBody({ body, fallback, hasDiff, status }: ToolBodyProps) {
+ const text = body || fallback;
+ const html = useMemo(() => {
+ if (!text.trim() || hasDiff || status === "pending") return "";
+ return renderMarkdown(text);
+ }, [text, hasDiff, status]);
+
+ if (!text.trim()) return null;
+
+ if (hasDiff) {
+ return (
+
+ );
+ }
+
+ if (status === "pending") {
+ return {text};
+ }
+
+ return (
+
+ );
+});
+
+export const ToolCallBlock = memo(function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
+ const { title, body, status } = useMemo(() => parseToolCallContent(content), [content]);
const hasDiff =
body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中";
+ const toolName = title.split("(")[0]?.trim() || deriveToolCallSummary(content);
+ const hasDetail = Boolean(body || (title.includes("(") && title !== toolName) || content !== title);
return (
-
+
- {title.split("(")[0] || deriveToolCallSummary(content)}
+
+ {toolName}
+
{statusLabel}
- {(body || content !== title) && (
+ {hasDetail ? (
{title.includes("(") ?
{title}
: null}
- {hasDiff ? (
-
- ) : (
-
{body || content}
- )}
+
- )}
+ ) : null}
);
-}
+});
\ No newline at end of file
diff --git a/frontend/src/components/chat/agentSegment.module.css b/frontend/src/components/chat/agentSegment.module.css
new file mode 100644
index 0000000..2762ed5
--- /dev/null
+++ b/frontend/src/components/chat/agentSegment.module.css
@@ -0,0 +1,114 @@
+.segment {
+ margin: 0;
+ border-radius: 10px 10px 10px 3px;
+ border: 1px solid #e8ecf0;
+ background: #fff;
+ overflow: hidden;
+ font-size: 14px;
+ line-height: 1.4;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ color: #1a1a1a;
+}
+
+.summary {
+ list-style: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 10px;
+ min-height: 0;
+ user-select: none;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.summary::-webkit-details-marker {
+ display: none;
+}
+
+.summary::before {
+ content: "";
+ flex-shrink: 0;
+ width: 0;
+ height: 0;
+ border-top: 3.5px solid transparent;
+ border-bottom: 3.5px solid transparent;
+ border-left: 4px solid #818b98;
+ transition: transform 0.15s ease;
+}
+
+.segment[open] > .summary::before {
+ transform: rotate(90deg);
+}
+
+.summaryTitle {
+ flex-shrink: 0;
+ font-weight: 500;
+ color: #57606a;
+}
+
+.summaryPreview {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #656d76;
+}
+
+.summaryTitleGrow {
+ flex: 1;
+ min-width: 0;
+ font-weight: 500;
+ color: #57606a;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.detail {
+ margin: 0;
+ padding: 0 10px 6px;
+ border-top: 1px solid #e8ecf0;
+ max-height: min(200px, 30vh);
+ overflow: auto;
+}
+
+.body {
+ margin: 0;
+ padding: 4px 0 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 13px;
+ line-height: 1.4;
+ color: #374151;
+}
+
+.bodyItalic {
+ font-style: italic;
+ color: #6b7280;
+}
+
+.textSegment {
+ composes: segment;
+ padding: 6px 10px;
+}
+
+.textBody {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.textBody:global(.markdown-body) {
+ font-size: 14px;
+ line-height: 1.4;
+ background: transparent;
+}
+
+.streamingPlain {
+ composes: textBody;
+}
\ No newline at end of file
diff --git a/frontend/src/context/ChatContext.tsx b/frontend/src/context/ChatContext.tsx
index 15c8164..a7c7f34 100644
--- a/frontend/src/context/ChatContext.tsx
+++ b/frontend/src/context/ChatContext.tsx
@@ -464,9 +464,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [toolsExpanded, setToolsExpanded] = useState(false);
const [showThinkingAndTools, setShowThinkingAndTools] = useState(() => {
try {
- return localStorage.getItem(SHOW_THINKING_TOOLS_KEY) === "true";
+ const stored = localStorage.getItem(SHOW_THINKING_TOOLS_KEY);
+ if (stored === null) return true;
+ return stored === "true";
} catch {
- return false;
+ return true;
}
});
const [isEphemeralSession, setIsEphemeralSession] = useState(false);
@@ -484,6 +486,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const compactionReloadPendingRef = useRef(false);
const activeBashMsgIdRef = useRef(null);
const sessionDashboardRaf = useRef(null);
+ const pendingAssistantBlocksRef = useRef(null);
+ const messageUpdateRafRef = useRef(null);
+ const pendingBashOutputRef = useRef(null);
+ const bashUpdateRafRef = useRef(null);
const backendSessionPathRef = useRef(backendSessionPath);
const activeSessionPathRef = useRef(activeSessionPath);
const isStreamingRef = useRef(isStreaming);
@@ -498,12 +504,91 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isStreamingRef.current = isStreaming;
}, [isStreaming]);
- const setMessages = useCallback((next: SetStateAction) => {
- setRawMessages((prev) => {
- const resolved = typeof next === "function" ? next(prev) : next;
- return normalizeChatMessages(resolved);
- });
- }, []);
+ const setMessages = useCallback(
+ (next: SetStateAction, options?: { skipNormalize?: boolean }) => {
+ setRawMessages((prev) => {
+ const resolved = typeof next === "function" ? next(prev) : next;
+ return options?.skipNormalize ? resolved : normalizeChatMessages(resolved);
+ });
+ },
+ [],
+ );
+
+ const flushPendingStreamUpdates = useCallback(() => {
+ if (messageUpdateRafRef.current != null) {
+ cancelAnimationFrame(messageUpdateRafRef.current);
+ messageUpdateRafRef.current = null;
+ }
+ if (bashUpdateRafRef.current != null) {
+ cancelAnimationFrame(bashUpdateRafRef.current);
+ bashUpdateRafRef.current = null;
+ }
+
+ const blocks = pendingAssistantBlocksRef.current;
+ const bashOutput = pendingBashOutputRef.current;
+ pendingAssistantBlocksRef.current = null;
+ pendingBashOutputRef.current = null;
+
+ if (blocks) {
+ setMessages(
+ (prev) => applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
+ { skipNormalize: true },
+ );
+ }
+ if (bashOutput != null) {
+ const bashId = activeBashMsgIdRef.current;
+ if (bashId) {
+ setMessages(
+ (prev) =>
+ prev.map((msg) =>
+ msg.id === bashId ? { ...msg, content: bashOutput, streaming: true } : msg,
+ ),
+ { skipNormalize: true },
+ );
+ }
+ }
+ }, [setMessages]);
+
+ const scheduleAssistantMessageUpdate = useCallback(
+ (blocks: AssistantBlock[]) => {
+ pendingAssistantBlocksRef.current = blocks;
+ if (messageUpdateRafRef.current != null) return;
+ messageUpdateRafRef.current = requestAnimationFrame(() => {
+ messageUpdateRafRef.current = null;
+ const pending = pendingAssistantBlocksRef.current;
+ if (!pending) return;
+ pendingAssistantBlocksRef.current = null;
+ setMessages(
+ (prev) => applyAssistantMessageUpdate(prev, pending, streamingAssistantId),
+ { skipNormalize: true },
+ );
+ });
+ },
+ [setMessages],
+ );
+
+ const scheduleBashOutputUpdate = useCallback(
+ (output: string) => {
+ pendingBashOutputRef.current = output;
+ if (bashUpdateRafRef.current != null) return;
+ bashUpdateRafRef.current = requestAnimationFrame(() => {
+ bashUpdateRafRef.current = null;
+ const pending = pendingBashOutputRef.current;
+ if (pending == null) return;
+ pendingBashOutputRef.current = null;
+ const bashId = activeBashMsgIdRef.current;
+ if (!bashId) return;
+ setMessages(
+ (prev) =>
+ prev.map((msg) =>
+ msg.id === bashId ? { ...msg, content: pending, streaming: true } : msg,
+ ),
+ { skipNormalize: true },
+ );
+ });
+ },
+ [setMessages],
+ );
const availableThinkingLevels = useMemo(
() => getAvailableThinkingLevels(sessionState?.model),
@@ -1482,6 +1567,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
break;
case "agent_end":
+ flushPendingStreamUpdates();
setIsStreaming(false);
setConnected(true);
setMessages((prev) => finalizeStreamingTurn(prev));
@@ -1537,14 +1623,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
} else if (hasTextBlock(blocks)) {
updateRunPhase("writing");
}
- setMessages((prev) =>
- applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
- );
+ scheduleAssistantMessageUpdate(blocks);
}
break;
}
case "message_end": {
+ flushPendingStreamUpdates();
const m = ev.message;
if (m.role === "assistant") {
const blocks = extractAssistantBlocks(m.content);
@@ -1574,25 +1659,28 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
updateRunPhase("tool", ev.toolName);
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
- setMessages((prev) => {
- const existingIdx = tid
- ? prev.findIndex((msg) => msg.toolCallId === tid)
- : -1;
- if (existingIdx >= 0) {
- return prev.map((msg, i) =>
- i === existingIdx ? { ...msg, content: label } : msg,
- );
- }
- return [
- ...prev,
- {
- id: tid ? `tool-${tid}` : nextMessageId(),
- role: "tool_call" as const,
- content: label,
- toolCallId: tid,
- },
- ];
- });
+ setMessages(
+ (prev) => {
+ const existingIdx = tid
+ ? prev.findIndex((msg) => msg.toolCallId === tid)
+ : -1;
+ if (existingIdx >= 0) {
+ return prev.map((msg, i) =>
+ i === existingIdx ? { ...msg, content: label } : msg,
+ );
+ }
+ return [
+ ...prev,
+ {
+ id: tid ? `tool-${tid}` : nextMessageId(),
+ role: "tool_call" as const,
+ content: label,
+ toolCallId: tid,
+ },
+ ];
+ },
+ { skipNormalize: true },
+ );
break;
}
@@ -1602,17 +1690,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
const full = buildToolCallUpdateText(ev, isError);
- setMessages((prev) => {
- let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
- if (idx < 0) {
- const toolCalls = prev.filter((m) => m.role === "tool_call");
- if (toolCalls.length > 0) {
- idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
+ setMessages(
+ (prev) => {
+ let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
+ if (idx < 0) {
+ const toolCalls = prev.filter((m) => m.role === "tool_call");
+ if (toolCalls.length > 0) {
+ idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
+ }
}
- }
- if (idx < 0) return prev;
- return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
- });
+ if (idx < 0) return prev;
+ return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
+ },
+ { skipNormalize: true },
+ );
break;
}
@@ -1692,9 +1783,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const bashId = activeBashMsgIdRef.current;
if (!bashId) break;
const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
- setMessages((prev) =>
- prev.map((msg) => (msg.id === bashId ? { ...msg, content: output, streaming: true } : msg)),
- );
+ scheduleBashOutputUpdate(output);
break;
}
}
@@ -1707,6 +1796,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
addSystemMessage,
resetStreamingState,
updateRunPhase,
+ scheduleAssistantMessageUpdate,
+ scheduleBashOutputUpdate,
+ flushPendingStreamUpdates,
],
);
@@ -1839,53 +1931,105 @@ export function ChatProvider({ children }: { children: ReactNode }) {
};
}, []);
- const value: ChatContextValue = {
- connected,
- isStreaming,
- messages,
- sessions,
- activeSessionPath,
- headerTitle,
- headerMeta,
- sidebarOpen,
- sessionState,
- availableModels,
- currentModelKey,
- thinkingLevel,
- availableThinkingLevels,
- isApplyingModelSettings,
- pendingSteering,
- pendingFollowUp,
- retryState,
- isCompacting,
- runPhase,
- extensionWidgets,
- extensionStatuses,
- extensionDialog,
- toolsExpanded,
- showThinkingAndTools,
- toggleSidebar: () => setSidebarOpen((o) => !o),
- closeSidebar: () => setSidebarOpen(false),
- loadSessions,
- loadSession,
- deleteSession: deleteSessionHandler,
- renameSession: renameSessionHandler,
- toggleSessionPin: toggleSessionPinHandler,
- newSession,
- newTemporarySession,
- isEphemeralSession,
- sendMessage,
- runBashCommand,
- abortStream,
- abortRetry,
- toggleToolsExpanded,
- toggleShowThinkingAndTools,
- respondExtensionDialog,
- dismissExtensionDialog,
- applyModelSelection,
- applyThinkingSelection,
- addSystemMessage,
- };
+ const toggleSidebar = useCallback(() => setSidebarOpen((o) => !o), []);
+ const closeSidebar = useCallback(() => setSidebarOpen(false), []);
+
+ const value = useMemo(
+ () => ({
+ connected,
+ isStreaming,
+ messages,
+ sessions,
+ activeSessionPath,
+ headerTitle,
+ headerMeta,
+ sidebarOpen,
+ sessionState,
+ availableModels,
+ currentModelKey,
+ thinkingLevel,
+ availableThinkingLevels,
+ isApplyingModelSettings,
+ pendingSteering,
+ pendingFollowUp,
+ retryState,
+ isCompacting,
+ runPhase,
+ extensionWidgets,
+ extensionStatuses,
+ extensionDialog,
+ toolsExpanded,
+ showThinkingAndTools,
+ toggleSidebar,
+ closeSidebar,
+ loadSessions,
+ loadSession,
+ deleteSession: deleteSessionHandler,
+ renameSession: renameSessionHandler,
+ toggleSessionPin: toggleSessionPinHandler,
+ newSession,
+ newTemporarySession,
+ isEphemeralSession,
+ sendMessage,
+ runBashCommand,
+ abortStream,
+ abortRetry,
+ toggleToolsExpanded,
+ toggleShowThinkingAndTools,
+ respondExtensionDialog,
+ dismissExtensionDialog,
+ applyModelSelection,
+ applyThinkingSelection,
+ addSystemMessage,
+ }),
+ [
+ connected,
+ isStreaming,
+ messages,
+ sessions,
+ activeSessionPath,
+ headerTitle,
+ headerMeta,
+ sidebarOpen,
+ sessionState,
+ availableModels,
+ currentModelKey,
+ thinkingLevel,
+ availableThinkingLevels,
+ isApplyingModelSettings,
+ pendingSteering,
+ pendingFollowUp,
+ retryState,
+ isCompacting,
+ runPhase,
+ extensionWidgets,
+ extensionStatuses,
+ extensionDialog,
+ toolsExpanded,
+ showThinkingAndTools,
+ toggleSidebar,
+ closeSidebar,
+ loadSessions,
+ loadSession,
+ deleteSessionHandler,
+ renameSessionHandler,
+ toggleSessionPinHandler,
+ newSession,
+ newTemporarySession,
+ isEphemeralSession,
+ sendMessage,
+ runBashCommand,
+ abortStream,
+ abortRetry,
+ toggleToolsExpanded,
+ toggleShowThinkingAndTools,
+ respondExtensionDialog,
+ dismissExtensionDialog,
+ applyModelSelection,
+ applyThinkingSelection,
+ addSystemMessage,
+ ],
+ );
return {children};
}
diff --git a/frontend/src/routes/SettingsPage.module.css b/frontend/src/routes/SettingsPage.module.css
index ce61b33..b49283a 100644
--- a/frontend/src/routes/SettingsPage.module.css
+++ b/frontend/src/routes/SettingsPage.module.css
@@ -208,28 +208,6 @@
padding: 24px 28px;
}
-.markdownBody :global(pre.assistant-pre) {
- margin: 0.5em 0 0.6em;
- padding: 0;
- background: transparent;
- border: 1px solid #d0d7de;
- border-radius: 6px;
- overflow-x: auto;
- line-height: 1.45;
-}
-
-.markdownBody :global(pre.assistant-pre code.hljs) {
- display: block;
- padding: 16px;
- background: #f6f8fa;
- border: none;
- color: inherit;
- font-size: 13px;
- line-height: 1.45;
- word-break: normal;
- tab-size: 2;
-}
-
.primaryBtn,
.secondaryBtn,
.linkBtn {
diff --git a/frontend/src/routes/SettingsPage.tsx b/frontend/src/routes/SettingsPage.tsx
index b7c233d..5171a3e 100644
--- a/frontend/src/routes/SettingsPage.tsx
+++ b/frontend/src/routes/SettingsPage.tsx
@@ -100,7 +100,7 @@ function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
}
if (extension.source?.startsWith("npm:")) return "npm";
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/");
- if (pathHint.includes("/.sproutclaw/agent/npm/node_modules/")) return "npm";
+ if (pathHint.includes("/agent/npm/node_modules/")) return "npm";
return "local";
}
@@ -1366,7 +1366,7 @@ export function SettingsPage() {
- 开启后在对话中显示 AI 的思考过程和工具调用详情,默认关闭。
+ 开启后在对话中显示 AI 的思考过程和工具调用;思考内容默认折叠,点击可展开。
@@ -1464,7 +1464,7 @@ export function SettingsPage() {
["数据目录", envInfo.dataDir],
["项目根目录", envInfo.repoRoot],
["工作目录", envInfo.cwd],
- ["Pi 命令", envInfo.piCmd],
+ ["SproutClaw 命令", envInfo.sproutclawCmd || envInfo.piCmd],
] as [string, string | undefined][]
)
.filter(([, v]) => v !== undefined && v !== "")
diff --git a/frontend/src/styles/markdown-body.css b/frontend/src/styles/markdown-body.css
new file mode 100644
index 0000000..6bbfd00
--- /dev/null
+++ b/frontend/src/styles/markdown-body.css
@@ -0,0 +1,22 @@
+@import "github-markdown-css/github-markdown.css";
+
+.markdown-body pre code.hljs {
+ display: block;
+ padding: 0;
+ overflow: visible;
+ line-height: inherit;
+ word-break: normal;
+ word-wrap: normal;
+ tab-size: 2;
+ background: transparent;
+ font-family: var(
+ --fontStack-monospace,
+ ui-monospace,
+ SFMono-Regular,
+ SF Mono,
+ Menlo,
+ Consolas,
+ Liberation Mono,
+ monospace
+ ) !important;
+}
diff --git a/frontend/src/types/events.ts b/frontend/src/types/events.ts
index e4bbf15..545c148 100644
--- a/frontend/src/types/events.ts
+++ b/frontend/src/types/events.ts
@@ -258,6 +258,7 @@ export interface EnvironmentInfo {
repoRoot?: string;
cwd?: string;
piCmd?: string;
+ sproutclawCmd?: string;
// Agent 工具
agentTools?: AgentToolInfo[];
// 兼容旧字段
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
index 370cfda..5bfbfb2 100644
--- a/frontend/src/utils/markdown.ts
+++ b/frontend/src/utils/markdown.ts
@@ -9,6 +9,7 @@ import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import { Marked, type Tokens } from "marked";
import "highlight.js/styles/github.min.css";
+import "../styles/markdown-body.css";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
@@ -50,7 +51,7 @@ function renderAssistantCodeBlock(token: Tokens.Code): string {
const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
- return `${innerHtml}
\n`;
+ return `${innerHtml}
\n`;
}
const marked = new Marked({
@@ -61,7 +62,18 @@ const marked = new Marked({
},
});
+const MARKDOWN_CACHE_MAX = 200;
+const markdownCache = new Map();
+
export function renderMarkdown(text: string): string {
if (!text) return "";
- return marked.parse(text) as string;
+ const cached = markdownCache.get(text);
+ if (cached !== undefined) return cached;
+ const html = marked.parse(text) as string;
+ if (markdownCache.size >= MARKDOWN_CACHE_MAX) {
+ const oldest = markdownCache.keys().next().value;
+ if (oldest !== undefined) markdownCache.delete(oldest);
+ }
+ markdownCache.set(text, html);
+ return html;
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index ed1de01..0a880fe 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -83,7 +83,11 @@ export default defineConfig(({ mode }) => {
if (id.includes("node_modules/@xterm")) {
return "xterm-vendor";
}
- if (id.includes("node_modules/highlight.js") || id.includes("node_modules/marked")) {
+ if (
+ id.includes("node_modules/highlight.js") ||
+ id.includes("node_modules/marked") ||
+ id.includes("node_modules/github-markdown-css")
+ ) {
return "markdown-vendor";
}
},
diff --git a/run-backend.bat b/run-backend.bat
index 9779bd3..c57b86d 100644
--- a/run-backend.bat
+++ b/run-backend.bat
@@ -6,7 +6,7 @@ rem -------------------------------------------------------
cd /d "%~dp0backend"
echo Starting Go backend on port %BACKEND_PORT%...
-go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.sproutclaw\agent" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\src\cli.ts"
+go run ./cmd/server --port %BACKEND_PORT% --agent-dir "%SPROUTCLAW_DIR%\.sproutclaw\agent" --repo-root "%SPROUTCLAW_DIR%" --pi-cmd "node %SPROUTCLAW_DIR%\packages\coding-agent\dist\cli.js"
echo.
pause >nul
diff --git a/run-backend.sh b/run-backend.sh
new file mode 100755
index 0000000..3e588c6
--- /dev/null
+++ b/run-backend.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SPROUTCLAW_DIR="${SPROUTCLAW_DIR:-$(cd "$SCRIPT_DIR/../sproutclaw" && pwd)}"
+BACKEND_PORT="${BACKEND_PORT:-19133}"
+AGENT_DIR="${SPROUTCLAW_AGENT_DIR:-$SPROUTCLAW_DIR/.sproutclaw/agent}"
+PI_CMD="${SPROUTCLAW_CMD:-node $SPROUTCLAW_DIR/packages/coding-agent/dist/cli.js}"
+
+cd "$SCRIPT_DIR/backend"
+echo "[run-backend] port=$BACKEND_PORT agent-dir=$AGENT_DIR"
+exec go run ./cmd/server \
+ --port "$BACKEND_PORT" \
+ --agent-dir "$AGENT_DIR" \
+ --repo-root "$SPROUTCLAW_DIR" \
+ --pi-cmd "$PI_CMD" \
+ --frontend-dist "$SCRIPT_DIR/frontend/dist" \
+ --data-dir "$SCRIPT_DIR/backend/data"