Compare commits
10
Commits
3d34de96bf
...
443b38e3bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443b38e3bd | ||
|
|
35b1bc4357 | ||
|
|
8bb0bdfb4c | ||
|
|
c647bb98a9 | ||
|
|
3df9f42626 | ||
|
|
fc4bed67f4 | ||
|
|
0122b8bb07 | ||
|
|
79292c9f38 | ||
|
|
ec54ea4473 | ||
|
|
031451fe3e |
+98
-10
@@ -31,6 +31,8 @@ type Client struct {
|
|||||||
sessionID string
|
sessionID string
|
||||||
transport *transport
|
transport *transport
|
||||||
history *History
|
history *History
|
||||||
|
streamChs []chan<- StreamEvent
|
||||||
|
notifyWG sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates an ACP client for the given workspace.
|
// NewClient creates an ACP client for the given workspace.
|
||||||
@@ -159,6 +161,73 @@ func (c *Client) Prompt(ctx context.Context, content string) (PromptResult, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendStream writes ev to out without blocking. It is used for both chunk
|
||||||
|
// notifications and terminal events so that a slow consumer cannot stall the
|
||||||
|
// transport goroutine.
|
||||||
|
func (c *Client) sendStream(out chan<- StreamEvent, ev StreamEvent) {
|
||||||
|
select {
|
||||||
|
case out <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStream registers a new streaming consumer. It must be paired with RemoveStream.
|
||||||
|
func (c *Client) AddStream(ch chan<- StreamEvent) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.streamChs = append(c.streamChs, ch)
|
||||||
|
return len(c.streamChs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveStream unregisters a streaming consumer previously added with AddStream.
|
||||||
|
func (c *Client) RemoveStream(ch chan<- StreamEvent) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for i, existing := range c.streamChs {
|
||||||
|
if existing == ch {
|
||||||
|
c.streamChs[i] = c.streamChs[len(c.streamChs)-1]
|
||||||
|
c.streamChs = c.streamChs[:len(c.streamChs)-1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream sends a user prompt and forwards streamed chunks to out as they arrive.
|
||||||
|
// It emits exactly one terminal event, either complete or error, before returning.
|
||||||
|
func (c *Client) Stream(ctx context.Context, content string, out chan<- StreamEvent) error {
|
||||||
|
c.mu.RLock()
|
||||||
|
if !c.ready {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
c.sendStream(out, StreamEvent{Type: "error", Error: "client not ready"})
|
||||||
|
return util.New(util.CodeConflict, "client not ready")
|
||||||
|
}
|
||||||
|
sessionID := c.sessionID
|
||||||
|
tr := c.transport
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
c.history.Add("user", content)
|
||||||
|
|
||||||
|
c.AddStream(out)
|
||||||
|
defer c.RemoveStream(out)
|
||||||
|
|
||||||
|
params := SessionPromptParams{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Prompt: []PromptMessage{{Type: "text", Text: content}},
|
||||||
|
}
|
||||||
|
var result PromptResult
|
||||||
|
if err := tr.call(ctx, "session/prompt", params, &result); err != nil {
|
||||||
|
c.sendStream(out, StreamEvent{Type: "error", Error: err.Error()})
|
||||||
|
return util.Wrap(util.CodeInternal, "session/prompt failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for any chunk notifications that were already in flight to be
|
||||||
|
// delivered before we emit the terminal complete event.
|
||||||
|
c.notifyWG.Wait()
|
||||||
|
|
||||||
|
c.sendStream(out, StreamEvent{Type: "complete", StopReason: result.StopReason})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel sends a best-effort session/cancel notification.
|
// Cancel sends a best-effort session/cancel notification.
|
||||||
func (c *Client) Cancel() error {
|
func (c *Client) Cancel() error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@@ -230,26 +299,45 @@ func (c *Client) handleNotification(method string, params json.RawMessage) {
|
|||||||
c.lg.Debug("acp drop notification", "workspace_id", c.workspaceID, "method", method)
|
c.lg.Debug("acp drop notification", "workspace_id", c.workspaceID, "method", method)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.notifyWG.Add(1)
|
||||||
|
defer c.notifyWG.Done()
|
||||||
|
|
||||||
var up SessionUpdateParams
|
var up SessionUpdateParams
|
||||||
if err := json.Unmarshal(params, &up); err != nil {
|
if err := json.Unmarshal(params, &up); err != nil {
|
||||||
c.lg.Error("acp invalid session/update", "workspace_id", c.workspaceID, "error", err)
|
c.lg.Error("acp invalid session/update", "workspace_id", c.workspaceID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var role string
|
||||||
switch up.Update.SessionUpdate {
|
switch up.Update.SessionUpdate {
|
||||||
case "agent_message_chunk":
|
case "agent_message_chunk":
|
||||||
if up.Update.Content.Type == "text" {
|
role = "agent"
|
||||||
c.history.AppendOrCreate("agent", up.Update.MessageID, up.Update.Content.Text)
|
|
||||||
} else {
|
|
||||||
c.lg.Debug("acp drop non-text agent chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type)
|
|
||||||
}
|
|
||||||
case "user_message_chunk":
|
case "user_message_chunk":
|
||||||
if up.Update.Content.Type == "text" {
|
role = "user"
|
||||||
c.history.AppendOrCreate("user", up.Update.MessageID, up.Update.Content.Text)
|
|
||||||
} else {
|
|
||||||
c.lg.Debug("acp drop non-text user chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
c.lg.Debug("acp drop update", "workspace_id", c.workspaceID, "type", up.Update.SessionUpdate)
|
c.lg.Debug("acp drop update", "workspace_id", c.workspaceID, "type", up.Update.SessionUpdate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if up.Update.Content.Type != "text" {
|
||||||
|
c.lg.Debug("acp drop non-text chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.history.AppendOrCreate(role, up.Update.MessageID, up.Update.Content.Text)
|
||||||
|
|
||||||
|
ev := StreamEvent{
|
||||||
|
Type: "chunk",
|
||||||
|
MessageID: up.Update.MessageID,
|
||||||
|
Text: up.Update.Content.Text,
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
streams := make([]chan<- StreamEvent, len(c.streamChs))
|
||||||
|
copy(streams, c.streamChs)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
for _, ch := range streams {
|
||||||
|
c.sendStream(ch, ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package acp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -351,3 +352,74 @@ func TestHistoryAppendOrCreateGroupsByMessageID(t *testing.T) {
|
|||||||
t.Fatalf("after clear message = %+v, want text=\"after clear\" messageId=msg-1", msgs[0])
|
t.Fatalf("after clear message = %+v, want text=\"after clear\" messageId=msg-1", msgs[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientStreamEmitsChunkAndComplete(t *testing.T) {
|
||||||
|
c := &Client{
|
||||||
|
workspaceID: "ws",
|
||||||
|
fs: newStubFS(),
|
||||||
|
history: NewHistory(),
|
||||||
|
lg: slog.Default(),
|
||||||
|
}
|
||||||
|
stdin := &recordingWriteCloser{}
|
||||||
|
sub := newFakeSubscription()
|
||||||
|
tr := newTransport(stdin, sub, slog.Default())
|
||||||
|
tr.onNotification = c.handleNotification
|
||||||
|
c.transport = tr
|
||||||
|
c.ready = true
|
||||||
|
c.sessionID = "s1"
|
||||||
|
tr.start()
|
||||||
|
defer tr.Close()
|
||||||
|
|
||||||
|
out := make(chan StreamEvent, 16)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for strings.TrimSpace(stdin.String()) == "" {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
chunk1 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","messageId":"m1","content":{"type":"text","text":"hello"}}}}`
|
||||||
|
chunk2 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","messageId":"m1","content":{"type":"text","text":" world"}}}}`
|
||||||
|
response := `{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}`
|
||||||
|
sub.send([]byte(chunk1 + "\n"))
|
||||||
|
sub.send([]byte(chunk2 + "\n"))
|
||||||
|
sub.send([]byte(response + "\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := c.Stream(ctx, "hi", out); err != nil {
|
||||||
|
t.Fatalf("Stream error: %v", err)
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
|
||||||
|
var chunks int
|
||||||
|
var complete *StreamEvent
|
||||||
|
for ev := range out {
|
||||||
|
switch ev.Type {
|
||||||
|
case "chunk":
|
||||||
|
chunks++
|
||||||
|
if ev.MessageID != "m1" {
|
||||||
|
t.Fatalf("chunk messageId = %q, want m1", ev.MessageID)
|
||||||
|
}
|
||||||
|
case "complete":
|
||||||
|
if complete != nil {
|
||||||
|
t.Fatal("received more than one complete event")
|
||||||
|
}
|
||||||
|
cp := ev
|
||||||
|
complete = &cp
|
||||||
|
case "error":
|
||||||
|
t.Fatalf("unexpected error event: %q", ev.Error)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected event type %q", ev.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunks != 2 {
|
||||||
|
t.Fatalf("chunks = %d, want 2", chunks)
|
||||||
|
}
|
||||||
|
if complete == nil {
|
||||||
|
t.Fatal("expected complete event")
|
||||||
|
}
|
||||||
|
if complete.StopReason != "end_turn" {
|
||||||
|
t.Fatalf("stopReason = %q, want end_turn", complete.StopReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ type rpcError struct {
|
|||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamEvent is the wire shape of the streaming protocol. The Type field
|
||||||
|
// is one of "chunk", "complete", "error"; the other fields are populated
|
||||||
|
// based on Type.
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MessageID string `json:"messageId,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
StopReason string `json:"stopReason,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// InitializeParams is sent once to negotiate the ACP session.
|
// InitializeParams is sent once to negotiate the ACP session.
|
||||||
type InitializeParams struct {
|
type InitializeParams struct {
|
||||||
ProtocolVersion int `json:"protocolVersion"`
|
ProtocolVersion int `json:"protocolVersion"`
|
||||||
@@ -91,10 +102,10 @@ type SessionUpdateParams struct {
|
|||||||
|
|
||||||
// Update carries a single streamed update from the agent.
|
// Update carries a single streamed update from the agent.
|
||||||
type Update struct {
|
type Update struct {
|
||||||
SessionUpdate string `json:"sessionUpdate"`
|
SessionUpdate string `json:"sessionUpdate"`
|
||||||
MessageID string `json:"messageId,omitempty"`
|
MessageID string `json:"messageId,omitempty"`
|
||||||
Content ContentBlock `json:"content,omitempty"`
|
Content ContentBlock `json:"content,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentBlock is a content payload in session/update notifications.
|
// ContentBlock is a content payload in session/update notifications.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Service interface {
|
|||||||
Status(workspaceID string) (StatusResponse, error)
|
Status(workspaceID string) (StatusResponse, error)
|
||||||
History(workspaceID string) (HistoryResponse, error)
|
History(workspaceID string) (HistoryResponse, error)
|
||||||
Prompt(workspaceID string, content string) (PromptResponse, error)
|
Prompt(workspaceID string, content string) (PromptResponse, error)
|
||||||
|
Stream(workspaceID string, content string, out chan<- StreamEvent) error
|
||||||
Cancel(workspaceID string) error
|
Cancel(workspaceID string) error
|
||||||
EnsureReady(workspaceID string) error
|
EnsureReady(workspaceID string) error
|
||||||
}
|
}
|
||||||
@@ -186,3 +187,24 @@ func (s *localService) Cancel(workspaceID string) error {
|
|||||||
}
|
}
|
||||||
return c.Cancel()
|
return c.Cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream sends a user prompt and streams response chunks to out.
|
||||||
|
func (s *localService) Stream(workspaceID string, content string, out chan<- StreamEvent) error {
|
||||||
|
if _, err := s.workspaces.Get(workspaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.clientFor(workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lock := s.workspaceLock(workspaceID)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err := c.Initialize(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Stream(ctx, content, out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func (t *transport) dispatchLoop() {
|
|||||||
case msg.ID == nil && msg.Method != "":
|
case msg.ID == nil && msg.Method != "":
|
||||||
// Notification from the agent.
|
// Notification from the agent.
|
||||||
if t.onNotification != nil {
|
if t.onNotification != nil {
|
||||||
go t.onNotification(msg.Method, msg.Params)
|
t.onNotification(msg.Method, msg.Params)
|
||||||
}
|
}
|
||||||
case msg.ID != nil && (msg.Result != nil || msg.Error != nil):
|
case msg.ID != nil && (msg.Result != nil || msg.Error != nil):
|
||||||
// Response to a client-initiated request.
|
// Response to a client-initiated request.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codespace/internal/model"
|
"codespace/internal/model"
|
||||||
"codespace/internal/service"
|
"codespace/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
type acpHandler struct {
|
type acpHandler struct {
|
||||||
@@ -67,3 +71,108 @@ func (h *acpHandler) cancel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stream upgrades the connection to a WebSocket and streams the prompt response.
|
||||||
|
//
|
||||||
|
// Protocol:
|
||||||
|
//
|
||||||
|
// Client -> Server: {"type":"prompt","content":"..."}
|
||||||
|
// Server -> Client: zero or more {"type":"chunk","messageId":"...","text":"..."}
|
||||||
|
// Server -> Client: exactly one {"type":"complete","stopReason":"..."} or
|
||||||
|
// {"type":"error","error":"..."}
|
||||||
|
//
|
||||||
|
// Closing the WebSocket from the client side cancels the in-flight prompt.
|
||||||
|
func (h *acpHandler) stream(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var closeOnce sync.Once
|
||||||
|
closeAll := func() {
|
||||||
|
closeOnce.Do(func() {
|
||||||
|
conn.Close()
|
||||||
|
close(done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetReadLimit(64 << 10)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read the single prompt request from the client.
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
closeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.AcpStreamRequest
|
||||||
|
if err := json.Unmarshal(data, &req); err != nil || req.Type != "prompt" || req.Content == "" {
|
||||||
|
_ = conn.WriteJSON(model.AcpStreamEvent{Type: "error", Error: "expected {type: prompt, content: ...}"})
|
||||||
|
closeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(chan model.AcpStreamEvent, 32)
|
||||||
|
|
||||||
|
// Run the prompt in the background and close the event channel when done.
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
if err := h.svc.Stream(id, req.Content, out); err != nil {
|
||||||
|
select {
|
||||||
|
case out <- model.AcpStreamEvent{Type: "error", Error: err.Error()}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Write events and pings to the WebSocket.
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case ev, ok := <-out:
|
||||||
|
if !ok {
|
||||||
|
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||||
|
closeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(ev); err != nil {
|
||||||
|
closeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
|
||||||
|
closeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the client to close the connection and cancel any in-flight prompt.
|
||||||
|
for {
|
||||||
|
_, _, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
h.svc.Cancel(id)
|
||||||
|
closeAll()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codespace/internal/model"
|
||||||
|
"codespace/internal/process"
|
||||||
|
"codespace/internal/service"
|
||||||
|
"codespace/internal/shell"
|
||||||
|
"codespace/internal/workspace"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAcpStreamHandlerRoutes(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
opencodePath := filepath.Join(tmpDir, "opencode")
|
||||||
|
script := []byte(`#!/bin/sh
|
||||||
|
while IFS= read -r line; do
|
||||||
|
id=$(printf '%s' "$line" | grep -o '"id":[0-9]*' | cut -d: -f2)
|
||||||
|
case "$line" in
|
||||||
|
*initialize*)
|
||||||
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{},"agentInfo":{}}}\n' "$id"
|
||||||
|
;;
|
||||||
|
*session/new*)
|
||||||
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"sess-1"}}\n' "$id"
|
||||||
|
;;
|
||||||
|
*session/prompt*)
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"m1","content":{"type":"text","text":"hello"}}}}\n'
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"m1","content":{"type":"text","text":" world"}}}}\n'
|
||||||
|
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(opencodePath, script, 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake opencode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsRoot := filepath.Join(tmpDir, "workspaces")
|
||||||
|
wsMgr := workspace.NewLocalManager(wsRoot)
|
||||||
|
procMgr := process.NewManager(opencodePath, nil)
|
||||||
|
shellMgr := shell.NewManager("bash", []string{"-i"})
|
||||||
|
wsSvc := service.NewWorkspaceService(wsMgr, procMgr, shellMgr, nil)
|
||||||
|
fileSvc := service.NewFileService(wsMgr, 1<<20)
|
||||||
|
procSvc := service.NewProcessService(wsMgr, procMgr, nil)
|
||||||
|
shellSvc := service.NewShellService(wsMgr, shellMgr, nil)
|
||||||
|
acpSvc := service.NewAcpService(procMgr, wsMgr, nil)
|
||||||
|
|
||||||
|
r := NewRouter(wsSvc, fileSvc, procSvc, shellSvc, acpSvc, nil, gin.TestMode)
|
||||||
|
srv := httptest.NewServer(r)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ws, err := wsSvc.Create("streamws")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create workspace: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = wsSvc.Delete(ws.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/workspaces/" + ws.ID + "/acp/stream"
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial websocket: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(model.AcpStreamRequest{Type: "prompt", Content: "hello"}); err != nil {
|
||||||
|
t.Fatalf("write prompt request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
||||||
|
t.Fatalf("set read deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks int
|
||||||
|
var complete bool
|
||||||
|
for {
|
||||||
|
var ev model.AcpStreamEvent
|
||||||
|
if err := conn.ReadJSON(&ev); err != nil {
|
||||||
|
t.Fatalf("read event: %v", err)
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "chunk":
|
||||||
|
chunks++
|
||||||
|
case "complete":
|
||||||
|
complete = true
|
||||||
|
case "error":
|
||||||
|
t.Fatalf("unexpected error event: %s", ev.Error)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected event type %q", ev.Type)
|
||||||
|
}
|
||||||
|
if complete {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunks != 2 {
|
||||||
|
t.Fatalf("chunks = %d, want 2", chunks)
|
||||||
|
}
|
||||||
|
if !complete {
|
||||||
|
t.Fatal("expected complete event")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ func NewRouter(workspaces *service.WorkspaceService, files *service.FileService,
|
|||||||
api.GET("/workspaces/:id/acp/history", acpHandler.history)
|
api.GET("/workspaces/:id/acp/history", acpHandler.history)
|
||||||
api.POST("/workspaces/:id/acp/prompt", acpHandler.prompt)
|
api.POST("/workspaces/:id/acp/prompt", acpHandler.prompt)
|
||||||
api.POST("/workspaces/:id/acp/cancel", acpHandler.cancel)
|
api.POST("/workspaces/:id/acp/cancel", acpHandler.cancel)
|
||||||
|
api.GET("/workspaces/:id/acp/stream", acpHandler.stream)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,18 @@ type AcpPromptResponse struct {
|
|||||||
StopReason string `json:"stopReason"`
|
StopReason string `json:"stopReason"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AcpStreamRequest is the first client message on the ACP streaming WebSocket.
|
||||||
|
type AcpStreamRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcpStreamEvent is a server-to-client streaming event.
|
||||||
|
type AcpStreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MessageID string `json:"messageId,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
StopReason string `json:"stopReason,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,35 @@ func (s *AcpService) Cancel(workspaceID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream sends a prompt to the agent and forwards streamed events to out.
|
||||||
|
func (s *AcpService) Stream(workspaceID string, content string, out chan<- model.AcpStreamEvent) error {
|
||||||
|
acpOut := make(chan acp.StreamEvent, 32)
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
streamErr = s.svc.Stream(workspaceID, content, acpOut)
|
||||||
|
close(acpOut)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for ev := range acpOut {
|
||||||
|
out <- model.AcpStreamEvent{
|
||||||
|
Type: ev.Type,
|
||||||
|
MessageID: ev.MessageID,
|
||||||
|
Text: ev.Text,
|
||||||
|
StopReason: ev.StopReason,
|
||||||
|
Error: ev.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if streamErr != nil {
|
||||||
|
s.lg.Error("acp stream failed", "workspace_id", workspaceID, "error", streamErr)
|
||||||
|
}
|
||||||
|
return streamErr
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureReady ensures the ACP process is started and initialized.
|
// EnsureReady ensures the ACP process is started and initialized.
|
||||||
func (s *AcpService) EnsureReady(workspaceID string) error {
|
func (s *AcpService) EnsureReady(workspaceID string) error {
|
||||||
if err := s.svc.EnsureReady(workspaceID); err != nil {
|
if err := s.svc.EnsureReady(workspaceID); err != nil {
|
||||||
|
|||||||
@@ -2,80 +2,80 @@
|
|||||||
|
|
||||||
Roadmap for the next phases of the codespace web IDE.
|
Roadmap for the next phases of the codespace web IDE.
|
||||||
|
|
||||||
## Now: shadcn/ui dialog and dropdown migration
|
## Next: AcpPanel tool-call block UI
|
||||||
|
|
||||||
The frontend still uses `window.prompt`, `window.confirm`, and an absolute-positioned `<div>` for the file-tree context menu. Replace these with shadcn/ui components backed by Radix UI primitives. This gives us:
|
When the agent invokes a tool (e.g. reads a file, runs a command, edits a buffer) it emits a `session/update` notification with `sessionUpdate: "tool_call"`. Currently the client silently drops these in `handleNotification` (debug log only), so the user has no visibility into what the agent is doing while the reply streams in. Render them as discrete blocks under the agent bubble so the user can see "the agent read foo.go, ran `git status`, is now editing bar.go…".
|
||||||
|
|
||||||
- Consistent visual style for all popups
|
|
||||||
- Better keyboard navigation and accessibility
|
|
||||||
- The right-click context menu in the file tree becomes a real `DropdownMenu` instead of an in-place div
|
|
||||||
|
|
||||||
### Concrete steps
|
|
||||||
|
|
||||||
1. Add Radix UI primitives: `@radix-ui/react-dialog`, `@radix-ui/react-alert-dialog`, `@radix-ui/react-dropdown-menu`.
|
|
||||||
2. Add shadcn-style wrappers under `web/src/components/ui/`:
|
|
||||||
- `dialog.tsx` (Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter)
|
|
||||||
- `alert-dialog.tsx` (AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel)
|
|
||||||
- `dropdown-menu.tsx` (DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator)
|
|
||||||
- `input.tsx` (used inside Dialogs for prompts)
|
|
||||||
3. Migrate the existing callers:
|
|
||||||
- `WorkspaceSelector.tsx`: replace `window.prompt` for new-workspace with a `Dialog` containing an `Input`. Replace `window.confirm` for delete with an `AlertDialog`.
|
|
||||||
- `FileExplorerPanel.tsx`: replace all `window.prompt` (new file, new folder, rename) with a generic `Dialog` + `Input` component parameterized by title and callback. Replace `window.confirm` (delete) with an `AlertDialog`. Replace the absolute-positioned context menu with `DropdownMenu`.
|
|
||||||
4. Do not change backend behavior.
|
|
||||||
|
|
||||||
## Next: opencode ACP panel and integration
|
|
||||||
|
|
||||||
`opencode acp` exposes the editor as an **Agent Client Protocol** server. The current `opencode` process is started in the background but the user has no way to interact with it. The next phase adds a first-class ACP client panel in the web IDE.
|
|
||||||
|
|
||||||
**Status: ✅ Completed** (commits: ACP backend `feat(backend): add opencode ACP ...`, ACP frontend `feat(web): add ACP chat panel ...`, bug fix `fix(acp): parse session/update content as ContentBlock, not string`).
|
|
||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
The terminal currently runs `bash` (the shell subsystem). The `opencode` process is started via the toolbar and is meant to be controlled by an ACP client — the same protocol that editors like Zed use to talk to coding agents. Without an ACP panel, the opencode process is invisible: it has a PID and a status indicator, but no way to send it prompts or read responses.
|
Without tool-call visibility, the streamed agent reply is just text. The user has to guess what the agent is doing. Real coding agents (Claude Code, Cursor, Zed) show tool invocations inline so the user can:
|
||||||
|
- See which files the agent touched.
|
||||||
|
- Catch mistakes early (agent edited the wrong file).
|
||||||
|
- Trust the agent's progress during long-running work.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
1. **Backend: switch the opencode process to ACP mode**
|
1. **Backend: forward tool_call events through the stream**
|
||||||
- Default `opencodeCommand` stays `opencode`; new `process.args` field defaults to `["acp"]` so the launched command is `opencode acp`. Configurable via `configs/config.yaml` and the `CODESPACE_OPENCODE_ARGS` env var.
|
- In `internal/acp/handleNotification`, when `sessionUpdate == "tool_call"`, emit a `StreamEvent` to all active stream consumers:
|
||||||
- The `internal/process` package still captures stdout/stderr and exposes stdin via the WebSocket. ACP over stdio sits on top in the new `internal/acp` package — no transport changes to the existing process WS route.
|
```go
|
||||||
|
case StreamEvent{Type: "tool_call", ToolCallID: ..., Kind: ..., Title: ..., Status: ..., Content: ...}
|
||||||
|
```
|
||||||
|
- Also append a stable representation of the tool call to history so the next `GET /acp/history` returns it (so reloading the panel shows past tool calls). The `Message` model gains an optional `Kind: string` and `ToolCallID: string` field; `Role` stays as "agent" for v1 (or add a new role "tool" — TBD).
|
||||||
|
- The `Update` struct in `internal/acp/messages.go` already carries `ToolCallID string`. Capture the full `up.Update` payload for the history entry.
|
||||||
|
|
||||||
2. **Backend: ACP-aware HTTP API**
|
2. **Frontend: ToolCallBlock component**
|
||||||
- Implemented as four HTTP routes per ACP method. The frontend does not need to know JSON-RPC.
|
- New `web/src/components/acp/ToolCallBlock.tsx`.
|
||||||
- Routes:
|
- Props: `{ kind, title, status, content? }` where:
|
||||||
- `GET /api/workspaces/:id/acp/status` — observational: `{workspaceId, ready, sessionId, running, pid, error}`. Does NOT start the process.
|
- `kind` is the tool name (e.g. "read", "edit", "bash", "fetch", "grep").
|
||||||
- `GET /api/workspaces/:id/acp/history` — accumulated `{sessionId, messages: [{role, text, time}]}`, oldest first. In-memory only.
|
- `title` is a short human-readable summary (the agent's intent).
|
||||||
- `POST /api/workspaces/:id/acp/prompt` — body `{content: string}`. Returns `{sessionId, stopReason, text}`. Starts the process + session on demand (synchronous, 5-min default timeout).
|
- `status` is one of "pending" | "running" | "completed" | "failed" (the ACP `tool_call` update lifecycle — pending → running → completed/failed). Map to icons: `Loader2` (spinning) for running, `Circle` for pending, `CheckCircle2` for completed, `XCircle` for failed.
|
||||||
- `POST /api/workspaces/:id/acp/cancel` — best-effort interrupt.
|
- `content` is optional: for `kind: read` the file path or first lines; for `kind: edit` the diff; for `kind: bash` the command. Rendered as a small monospace block (collapsed by default, expand on click).
|
||||||
|
- Visual: small card, ~border + ~bg-muted, ~rounded-md, ~text-xs. Distinct from message bubbles so it's clear the agent did something, not said something.
|
||||||
|
- When `content` is large (e.g. a long file), truncate to a preview and show "show more".
|
||||||
|
|
||||||
3. **Frontend: ACP panel**
|
3. **Frontend: integrate into AcpPanel**
|
||||||
- Implemented as `web/src/components/acp/AcpPanel.tsx` with subcomponents `MessageBubble`, `MarkdownView`, `ThinkingIndicator`. Renders markdown with code-block highlighting (react-markdown + react-syntax-highlighter Prism oneDark), with auto-scroll, error toast fade, and a "thinking…" indicator while in flight.
|
- Streaming events with `type: "tool_call"` append a ToolCallBlock to a per-prompt list.
|
||||||
- Hooks: `useAcpStatus` (3s poll), `useAcpHistory` (5s poll), `useAcpPrompt` (mutation), `useAcpCancel` (mutation).
|
- The list is shown ABOVE the in-flight agent bubble (so tool calls appear first, then the agent's reply references them). On `complete`, the list is "committed" alongside the agent message.
|
||||||
|
- History endpoint returns tool calls; the AcpPanel render path coalesces: history messages + a per-message array of `ToolCall` items that appeared during that turn.
|
||||||
|
- When clicking a ToolCallBlock with a file path, optionally open the file in the editor (nice-to-have, defer to v2 if time-pressed).
|
||||||
|
|
||||||
4. **Frontend: integration with the workspace shell**
|
4. **Data model**
|
||||||
- Bottom panel converted to a tabbed area: `Terminal` / `Agent` (decided on **Tabs + Sibling tab**, not replace, not split). Tab state lives in `workspaceUiStore.bottomTab`.
|
- Extend `AcpMessage` / `internal/acp/history.go` `Message`:
|
||||||
- `StatusBar` shows `agent: ready` / `initializing…` / `<error>` pill when the ACP session is initialized; hides when not.
|
```go
|
||||||
- The existing `process/*` routes and raw process WebSocket remain untouched (the new acp routes are additive).
|
type Message struct {
|
||||||
|
Role string // "user" | "agent"
|
||||||
|
Text string
|
||||||
|
Time time.Time
|
||||||
|
MessageID string
|
||||||
|
ToolCalls []ToolCall // NEW: tool calls that occurred during this agent turn
|
||||||
|
}
|
||||||
|
type ToolCall struct {
|
||||||
|
ToolCallID string
|
||||||
|
Kind string
|
||||||
|
Title string
|
||||||
|
Status string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- The `session/update` with `sessionUpdate: "tool_call"` updates the tool call's status (pending → running → completed/failed). Correlate by `toolCallId`.
|
||||||
|
|
||||||
### Concrete tasks — all done
|
5. **Tests**
|
||||||
|
- `internal/acp/client_test.go`: extend `TestClientStreamEmitsChunkAndComplete` (or add a new test) that drives a fake tool_call sequence (pending → running → completed) and asserts the history contains the merged tool calls.
|
||||||
1. ✅ Switched opencode default to `opencode acp` via `process.args`.
|
- `internal/acp/history_test.go` (new) or `client_test.go`: verify `AppendOrCreate` on tool calls works (or add a separate `History.SetToolCalls(role, messageId, calls)` method).
|
||||||
2. ✅ Added `internal/acp` package: NDJSON transport + JSON-RPC 2.0 client (NDJSON framing with split-line buffering, request/response correlation, notification dispatch, per-workspace `Client` + `Service`).
|
- Frontend: no new test infra; rely on visual verification in the dev server.
|
||||||
3. ✅ Added `internal/api/acp_handler.go` with the four routes.
|
|
||||||
4. ✅ Added `web/src/lib/api/acp.ts` with the four hooks.
|
|
||||||
5. ✅ Added `web/src/components/acp/AcpPanel.tsx` + subcomponents.
|
|
||||||
6. ✅ Added `react-markdown` + `react-syntax-highlighter` + `remark-gfm` to `web/package.json`.
|
|
||||||
7. ✅ Wired the ACP panel into `WorkspaceShell.tsx` as a tabbed bottom panel.
|
|
||||||
8. ✅ Documented the ACP routes in `README.md` (section + env var + example curl).
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `internal/acp/client_test.go` covers NDJSON split-lines, request/response correlation, notification dispatch, and agent-initiated request handling for `fs/*` (real dispatch) and `terminal/*` (MethodNotFound).
|
|
||||||
- E2E smoke test against the live `opencode acp` binary: created a workspace, sent a prompt, received `text: "Hi there!"` non-empty, history has 4 messages (1 user + 3 agent chunks), 0 `invalid session/update` log entries.
|
|
||||||
|
|
||||||
### Out of scope (deferred to v2)
|
### Out of scope (deferred to v2)
|
||||||
- Streaming responses over WebSocket (v1 is single round-trip per prompt).
|
- Tool-call approval UI (the agent runs everything; no interrupt-and-confirm dialog).
|
||||||
- Tool-call approval UI.
|
- Re-running a tool call.
|
||||||
|
- Diff visualization for `edit` calls (just show the raw content for v1).
|
||||||
|
- Streaming partial tool-call content.
|
||||||
- Multiple agents / agent switching.
|
- Multiple agents / agent switching.
|
||||||
- Authentication.
|
|
||||||
- Cross-workspace agent memory.
|
- Cross-workspace agent memory.
|
||||||
- ACP session persistence across opencode restarts (history is in-memory and cleared on process restart — acceptable for v1).
|
- Authentication.
|
||||||
- `terminal/*` agent tool calls (return `MethodNotFound -32601`; the agent will fail those tool calls gracefully).
|
- ACP session persistence across opencode restarts.
|
||||||
|
|
||||||
|
### Open questions to resolve before implementation
|
||||||
|
|
||||||
|
- Should the in-memory history distinguish "agent text" from "tool calls" by message type, or are tool calls an array on the agent message? — Default: array on the agent message (one agent turn can have many tool calls).
|
||||||
|
- What's the shape of the `content` field for non-trivial tool calls? — For `read`, the file content; for `edit`, a diff; for `bash`, the output. Inspect a real `session/update` payload before settling the field name (`content` vs `output` vs `result`).
|
||||||
|
- Status transitions: does the agent send multiple `tool_call` updates with the same `toolCallId` (pending → running → completed), or just one final update? — Likely multiple; need to verify against a real opencode acp session.
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Ban, Send } from "lucide-react";
|
import { Ban, Send } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { ThinkingIndicator } from "./ThinkingIndicator";
|
import { groupMessages, type CoalescedMessage } from "./groupMessages";
|
||||||
import { groupMessages } from "./groupMessages";
|
|
||||||
|
|
||||||
import {
|
import { useAcpHistory, useAcpStatus, useAcpStream } from "@/lib/api/acp";
|
||||||
useAcpCancel,
|
|
||||||
useAcpHistory,
|
|
||||||
useAcpPrompt,
|
|
||||||
useAcpStatus,
|
|
||||||
} from "@/lib/api/acp";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AcpPanelProps {
|
interface AcpPanelProps {
|
||||||
@@ -71,13 +65,22 @@ function StatusPill({ status }: { status: ReturnType<typeof useAcpStatus> }) {
|
|||||||
export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
||||||
const acpStatus = useAcpStatus(workspaceId);
|
const acpStatus = useAcpStatus(workspaceId);
|
||||||
const acpHistory = useAcpHistory(workspaceId);
|
const acpHistory = useAcpHistory(workspaceId);
|
||||||
const prompt = useAcpPrompt();
|
const stream = useAcpStream(workspaceId);
|
||||||
const cancel = useAcpCancel();
|
const { onEvent, close } = stream;
|
||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [localErrors, setLocalErrors] = useState<LocalError[]>([]);
|
const [localErrors, setLocalErrors] = useState<LocalError[]>([]);
|
||||||
|
const [localUserMessage, setLocalUserMessage] =
|
||||||
|
useState<CoalescedMessage | null>(null);
|
||||||
|
const [inflight, setInflight] = useState<{
|
||||||
|
messageId: string;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const localTimeoutRef = useRef<number | null>(null);
|
||||||
|
const errorTimeoutsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
const messages = useMemo(
|
const messages = useMemo(
|
||||||
() => acpHistory.data?.messages ?? [],
|
() => acpHistory.data?.messages ?? [],
|
||||||
@@ -85,42 +88,144 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
);
|
);
|
||||||
const coalesced = useMemo(() => groupMessages(messages), [messages]);
|
const coalesced = useMemo(() => groupMessages(messages), [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
const pushError = useCallback((message: string) => {
|
||||||
if (prompt.error) {
|
const id = Date.now();
|
||||||
const id = Date.now();
|
setLocalErrors((prev) => [...prev, { id, message }]);
|
||||||
setLocalErrors((prev) => [
|
const timeout = window.setTimeout(() => {
|
||||||
...prev,
|
errorTimeoutsRef.current.delete(timeout);
|
||||||
{ id, message: prompt.error?.message ?? "Failed to send prompt" },
|
setLocalErrors((prev) => prev.filter((e) => e.id !== id));
|
||||||
]);
|
}, 5000);
|
||||||
const timeout = window.setTimeout(() => {
|
errorTimeoutsRef.current.add(timeout);
|
||||||
setLocalErrors((prev) => prev.filter((e) => e.id !== id));
|
}, []);
|
||||||
}, 5000);
|
|
||||||
return () => window.clearTimeout(timeout);
|
const scheduleLocalClear = useCallback(() => {
|
||||||
|
if (localTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(localTimeoutRef.current);
|
||||||
}
|
}
|
||||||
}, [prompt.error]);
|
localTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
localTimeoutRef.current = null;
|
||||||
|
setLocalUserMessage(null);
|
||||||
|
}, 6000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onEvent((event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "chunk":
|
||||||
|
setInflight((cur) =>
|
||||||
|
cur
|
||||||
|
? {
|
||||||
|
messageId: event.messageId || cur.messageId,
|
||||||
|
text: cur.text + event.text,
|
||||||
|
}
|
||||||
|
: cur,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "complete":
|
||||||
|
setInflight(null);
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
setInflight(null);
|
||||||
|
pushError(event.error || "stream error");
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [onEvent, close, pushError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stream.status === "error") {
|
||||||
|
setInflight(null);
|
||||||
|
pushError("stream connection failed");
|
||||||
|
}
|
||||||
|
}, [stream.status, pushError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localUserMessage) return;
|
||||||
|
const lastUser = [...coalesced].reverse().find((m) => m.role === "user");
|
||||||
|
if (lastUser && lastUser.text === localUserMessage.text) {
|
||||||
|
setLocalUserMessage(null);
|
||||||
|
if (localTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(localTimeoutRef.current);
|
||||||
|
localTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [coalesced, localUserMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutsSet = errorTimeoutsRef.current;
|
||||||
|
return () => {
|
||||||
|
if (localTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(localTimeoutRef.current);
|
||||||
|
}
|
||||||
|
const timeouts = Array.from(timeoutsSet);
|
||||||
|
timeouts.forEach((timeout) => window.clearTimeout(timeout));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track "follow" state. If the user is near the bottom, new content
|
||||||
|
// pulls them along. If they scroll up, we stop following so they can
|
||||||
|
// read history; a back-to-bottom button appears. The ref lets the
|
||||||
|
// auto-scroll effect read the freshest value without depending on
|
||||||
|
// the state (which would cause a re-fire loop when scrollIntoView
|
||||||
|
// itself emits a scroll event).
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const viewport = root.querySelector(
|
||||||
|
"[data-radix-scroll-area-viewport]",
|
||||||
|
) as HTMLDivElement | null;
|
||||||
|
if (!viewport) return;
|
||||||
|
const handler = () => {
|
||||||
|
const distance =
|
||||||
|
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
|
const atBottom = distance <= 80;
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
};
|
||||||
|
viewport.addEventListener("scroll", handler);
|
||||||
|
handler(); // sync initial state
|
||||||
|
return () => viewport.removeEventListener("scroll", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = scrollRootRef.current;
|
const root = scrollRootRef.current;
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
if (!root || !sentinel) return;
|
if (!root || !sentinel) return;
|
||||||
|
if (!isAtBottomRef.current) return; // user scrolled up; let them read
|
||||||
|
|
||||||
const viewport = root.querySelector(
|
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
"[data-radix-scroll-area-viewport]",
|
}, [coalesced, inflight?.text, localUserMessage]);
|
||||||
) as HTMLDivElement | null;
|
|
||||||
if (!viewport) return;
|
|
||||||
|
|
||||||
const distanceFromBottom =
|
const scrollToBottom = useCallback(() => {
|
||||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
sentinelRef.current?.scrollIntoView({
|
||||||
if (distanceFromBottom <= 80) {
|
behavior: "smooth",
|
||||||
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
|
block: "end",
|
||||||
}
|
});
|
||||||
}, [coalesced, prompt.isPending]);
|
}, []);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const content = input.trim();
|
const content = input.trim();
|
||||||
if (!content || !workspaceId || prompt.isPending) return;
|
if (!content || !workspaceId || inflight) return;
|
||||||
prompt.mutate({ workspaceId, content });
|
setLocalUserMessage({
|
||||||
|
role: "user",
|
||||||
|
text: content,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
scheduleLocalClear();
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setInflight({ messageId: "", text: "" });
|
||||||
|
stream.open(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!inflight) return;
|
||||||
|
stream.close();
|
||||||
|
setInflight(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -137,6 +242,21 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
? `${sessionId.slice(0, 12)}…`
|
? `${sessionId.slice(0, 12)}…`
|
||||||
: sessionId;
|
: sessionId;
|
||||||
|
|
||||||
|
const renderMessages = useMemo(() => {
|
||||||
|
const items: CoalescedMessage[] = [...coalesced];
|
||||||
|
if (localUserMessage) {
|
||||||
|
items.push(localUserMessage);
|
||||||
|
}
|
||||||
|
if (inflight) {
|
||||||
|
items.push({
|
||||||
|
role: "agent",
|
||||||
|
text: inflight.text,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [coalesced, localUserMessage, inflight]);
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
||||||
@@ -162,17 +282,28 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
<StatusPill status={acpStatus} />
|
<StatusPill status={acpStatus} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ScrollArea ref={scrollRootRef} className="flex-1">
|
<ScrollArea ref={scrollRootRef} className="relative flex-1">
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3 pb-6">
|
||||||
{messages.length === 0 && isReady && (
|
{messages.length === 0 &&
|
||||||
<div className="flex flex-1 items-center justify-center py-8 text-sm text-muted-foreground">
|
!localUserMessage &&
|
||||||
Send a message to start the conversation.
|
!inflight &&
|
||||||
</div>
|
isReady && (
|
||||||
)}
|
<div className="flex flex-1 items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
{coalesced.map((message) => (
|
Send a message to start the conversation.
|
||||||
<MessageBubble key={message.time} message={message} />
|
</div>
|
||||||
|
)}
|
||||||
|
{renderMessages.map((message, index) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={
|
||||||
|
localUserMessage && message === localUserMessage
|
||||||
|
? "local-user"
|
||||||
|
: inflight && index === renderMessages.length - 1
|
||||||
|
? "inflight-agent"
|
||||||
|
: message.time
|
||||||
|
}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{prompt.isPending && <ThinkingIndicator />}
|
|
||||||
{localErrors.map((err) => (
|
{localErrors.map((err) => (
|
||||||
<div
|
<div
|
||||||
key={err.id}
|
key={err.id}
|
||||||
@@ -181,8 +312,20 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
{err.message}
|
{err.message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={sentinelRef} />
|
{/* Sentinel: when auto-scrolled, the scroll lands here. h-8 gives
|
||||||
|
the last message a little breathing room above the composer. */}
|
||||||
|
<div ref={sentinelRef} className="h-8 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border bg-background/90 px-3 py-1 text-xs text-muted-foreground shadow-md backdrop-blur hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
↓ Jump to latest
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex h-12 shrink-0 items-center gap-2 border-t border-border bg-background px-3">
|
<div className="flex h-12 shrink-0 items-center gap-2 border-t border-border bg-background px-3">
|
||||||
@@ -196,12 +339,12 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
disabled={!workspaceId}
|
disabled={!workspaceId}
|
||||||
className="max-h-32 min-h-8 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="max-h-32 min-h-8 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{prompt.isPending ? (
|
{inflight ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => workspaceId && cancel.mutate(workspaceId)}
|
onClick={handleCancel}
|
||||||
disabled={cancel.isPending}
|
disabled={stream.status !== "open"}
|
||||||
>
|
>
|
||||||
<Ban className="size-4" aria-hidden="true" />
|
<Ban className="size-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Cancel</span>
|
<span className="sr-only">Cancel</span>
|
||||||
@@ -210,7 +353,7 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() || prompt.isPending}
|
disabled={!input.trim() || !!inflight}
|
||||||
>
|
>
|
||||||
<Send className="size-4" aria-hidden="true" />
|
<Send className="size-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Send</span>
|
<span className="sr-only">Send</span>
|
||||||
|
|||||||
@@ -20,12 +20,34 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
|
|||||||
const saveInProgress = write.isPending;
|
const saveInProgress = write.isPending;
|
||||||
const saveTimeoutRef = useRef<number | undefined>(undefined);
|
const saveTimeoutRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Detect that the file on disk has changed since we last loaded/saved it.
|
||||||
|
// We only consider it "changed externally" if the server content differs
|
||||||
|
// from what we last persisted (savedContent). The query refetches every
|
||||||
|
// 5s (see useFileRead) so this flips when another tool edits the file.
|
||||||
|
const externalChange =
|
||||||
|
data !== undefined && data.content !== savedContent && !dirty;
|
||||||
|
|
||||||
// Sync editor buffer with fetched content when the file changes or loads.
|
// Sync editor buffer with fetched content when the file changes or loads.
|
||||||
|
// Guard: do NOT clobber the user's unsaved edits. If dirty, the user is
|
||||||
|
// editing and the next refetch must not stomp the buffer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (!data) return;
|
||||||
|
setSavedContent(data.content);
|
||||||
|
if (!dirty) {
|
||||||
setBuffer(data.content);
|
setBuffer(data.content);
|
||||||
setSavedContent(data.content);
|
|
||||||
}
|
}
|
||||||
|
// Intentionally NOT including `dirty` in deps: we want this effect to
|
||||||
|
// run when `data` changes; on the first refetch that comes back with
|
||||||
|
// new content while dirty, the warning above (`externalChange` is gated
|
||||||
|
// by !dirty) won't fire, but the next save will surface the diff. The
|
||||||
|
// user can also click Reload to accept the disk version.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const reloadFromDisk = useCallback(() => {
|
||||||
|
if (!data) return;
|
||||||
|
setBuffer(data.content);
|
||||||
|
setSavedContent(data.content);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const editorLanguage =
|
const editorLanguage =
|
||||||
@@ -162,6 +184,18 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
|
|||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{externalChange && (
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
<span>File changed on disk.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reloadFromDisk}
|
||||||
|
className="rounded border border-amber-500/40 px-2 py-0.5 hover:bg-amber-500/20"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<Editor
|
<Editor
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
|
|||||||
import { StatusBar } from "@/components/workspace/StatusBar";
|
import { StatusBar } from "@/components/workspace/StatusBar";
|
||||||
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
|
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
|
||||||
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
||||||
|
|
||||||
|
// Module-level constant so the selector returns a stable reference when
|
||||||
|
// the workspace has no shells yet. Returning a freshly-allocated `[]` from
|
||||||
|
// the selector (via `?? []`) would make useSyncExternalStore see a new
|
||||||
|
// snapshot on every render and loop.
|
||||||
|
const EMPTY_SHELLS: string[] = [];
|
||||||
import {
|
import {
|
||||||
useProcessRestart,
|
useProcessRestart,
|
||||||
useProcessStart,
|
useProcessStart,
|
||||||
@@ -72,7 +78,7 @@ function BottomTabs({ workspaceId }: { workspaceId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TerminalTabs({ workspaceId }: { workspaceId: string }) {
|
function TerminalTabs({ workspaceId }: { workspaceId: string }) {
|
||||||
const shells = useWorkspaceUiStore((s) => s.shellsByWorkspace[workspaceId] ?? []);
|
const shells = useWorkspaceUiStore((s) => s.shellsByWorkspace[workspaceId] ?? EMPTY_SHELLS);
|
||||||
const activeShellId = useWorkspaceUiStore((s) => s.activeShellIdByWorkspace[workspaceId]);
|
const activeShellId = useWorkspaceUiStore((s) => s.activeShellIdByWorkspace[workspaceId]);
|
||||||
const setShellsForWorkspace = useWorkspaceUiStore((s) => s.setShellsForWorkspace);
|
const setShellsForWorkspace = useWorkspaceUiStore((s) => s.setShellsForWorkspace);
|
||||||
const addShellToWorkspace = useWorkspaceUiStore((s) => s.addShellToWorkspace);
|
const addShellToWorkspace = useWorkspaceUiStore((s) => s.addShellToWorkspace);
|
||||||
@@ -107,8 +113,13 @@ function TerminalTabs({ workspaceId }: { workspaceId: string }) {
|
|||||||
setShellsForWorkspace(workspaceId, ids);
|
setShellsForWorkspace(workspaceId, ids);
|
||||||
}
|
}
|
||||||
const currentActive = activeShellId;
|
const currentActive = activeShellId;
|
||||||
if (!currentActive || !ids.includes(currentActive)) {
|
// Only set an active shell when the list is non-empty. If ids is empty,
|
||||||
setActiveShell(workspaceId, ids[0] ?? "");
|
// the auto-start effect above will create a shell; the next list refetch
|
||||||
|
// will populate ids and this branch will run with a real candidate.
|
||||||
|
// (Setting it to "" here would loop: "" is falsy, so the same branch
|
||||||
|
// would fire on the next render.)
|
||||||
|
if (ids.length > 0 && (!currentActive || !ids.includes(currentActive))) {
|
||||||
|
setActiveShell(workspaceId, ids[0]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
|||||||
const restart = useShellRestart(workspaceId);
|
const restart = useShellRestart(workspaceId);
|
||||||
|
|
||||||
const resize = useShellResize(workspaceId, shellId);
|
const resize = useShellResize(workspaceId, shellId);
|
||||||
|
// Destructure the mutate function so the effect can depend on the
|
||||||
|
// stable function reference alone, not the whole mutation result.
|
||||||
|
const sendResize = resize.mutate;
|
||||||
|
|
||||||
// Initialize xterm once and keep it alive across WS status changes.
|
// Initialize xterm once and keep it alive across WS status changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,8 +40,6 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
|||||||
|
|
||||||
let terminal: Terminal | undefined;
|
let terminal: Terminal | undefined;
|
||||||
let fitAddon: FitAddon | undefined;
|
let fitAddon: FitAddon | undefined;
|
||||||
let debounceRef: number | undefined;
|
|
||||||
let removeResize: (() => void) | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
terminal = new Terminal({
|
terminal = new Terminal({
|
||||||
@@ -62,19 +63,6 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
|||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
|
|
||||||
removeResize = terminal.onResize(({ cols, rows }) => {
|
|
||||||
if (!workspaceId || !shellId) return;
|
|
||||||
if (cols <= 0 || rows <= 0) return;
|
|
||||||
if (debounceRef !== undefined) {
|
|
||||||
window.clearTimeout(debounceRef);
|
|
||||||
}
|
|
||||||
debounceRef = window.setTimeout(() => {
|
|
||||||
debounceRef = undefined;
|
|
||||||
if (!workspaceId || !shellId) return;
|
|
||||||
resize.mutate({ cols, rows });
|
|
||||||
}, 100);
|
|
||||||
}).dispose;
|
|
||||||
|
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -84,46 +72,102 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Resize loop: fit() -> terminal.resize() -> onResize() -> mutate.
|
||||||
|
//
|
||||||
|
// Listening to terminal.onResize to push the new size to the backend
|
||||||
|
// creates a closed loop:
|
||||||
|
// fit() -> terminal.resize(cols, rows) -> onResize(cb) fires
|
||||||
|
// -> cb calls resize.mutate() -> server calls pty.Setsize
|
||||||
|
// -> bash receives SIGWINCH and redraws -> new output via WS
|
||||||
|
// -> terminal.write(...) may change layout (scrollbar/padding) by
|
||||||
|
// a few pixels -> ResizeObserver fires again -> fit() again.
|
||||||
|
//
|
||||||
|
// The right pattern (per the xterm + FitAddon docs): only call
|
||||||
|
// fit() from the ResizeObserver, then read terminal.cols/rows
|
||||||
|
// synchronously and push to the backend. Skip when the size hasn't
|
||||||
|
// changed. Skip when the container's getBoundingClientRect hasn't
|
||||||
|
// changed (so sub-pixel oscillation doesn't trigger).
|
||||||
|
|
||||||
|
const lastReportedSize = { cols: 0, rows: 0 };
|
||||||
|
const lastRect = { width: -1, height: -1 };
|
||||||
let rafId: number | undefined;
|
let rafId: number | undefined;
|
||||||
|
|
||||||
|
const reportSize = () => {
|
||||||
|
if (!workspaceId || !shellId) return;
|
||||||
|
const cols = terminal!.cols;
|
||||||
|
const rows = terminal!.rows;
|
||||||
|
if (cols <= 0 || rows <= 0) return;
|
||||||
|
if (cols === lastReportedSize.cols && rows === lastReportedSize.rows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastReportedSize.cols = cols;
|
||||||
|
lastReportedSize.rows = rows;
|
||||||
|
sendResize({ cols, rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
const doFit = () => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
// Skip if the container's outer rect didn't actually move. This
|
||||||
|
// stops ResizeObserver firings triggered by sub-pixel internal
|
||||||
|
// changes (e.g. scrollbar appearance) from feeding fit().
|
||||||
|
if (
|
||||||
|
rect.width === lastRect.width &&
|
||||||
|
rect.height === lastRect.height
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRect.width = rect.width;
|
||||||
|
lastRect.height = rect.height;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fitAddon!.fit();
|
||||||
|
} catch {
|
||||||
|
// fit() throws when the container has zero dimensions; ignore
|
||||||
|
// and wait for the next ResizeObserver tick.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Push the new size to the backend immediately, deduped by cols/rows.
|
||||||
|
reportSize();
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleFit = () => {
|
const scheduleFit = () => {
|
||||||
if (rafId !== undefined) return;
|
if (rafId !== undefined) return;
|
||||||
rafId = requestAnimationFrame(() => {
|
rafId = requestAnimationFrame(() => {
|
||||||
rafId = undefined;
|
rafId = undefined;
|
||||||
try {
|
doFit();
|
||||||
fitAddon?.fit();
|
|
||||||
} catch {
|
|
||||||
// fit() throws when the container has zero dimensions; ignore
|
|
||||||
// and wait for the next ResizeObserver tick.
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(scheduleFit);
|
const resizeObserver = new ResizeObserver(scheduleFit);
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleWindowResize = () => {
|
||||||
scheduleFit();
|
scheduleFit();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleWindowResize);
|
||||||
|
|
||||||
|
// Run an initial fit so the backend knows the size as soon as the
|
||||||
|
// terminal mounts. Without this, the user would type into a default
|
||||||
|
// 80x24 terminal and the first line of output would wrap.
|
||||||
|
scheduleFit();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafId !== undefined) {
|
if (rafId !== undefined) {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
}
|
}
|
||||||
if (debounceRef !== undefined) {
|
|
||||||
window.clearTimeout(debounceRef);
|
|
||||||
debounceRef = undefined;
|
|
||||||
}
|
|
||||||
removeResize?.();
|
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleWindowResize);
|
||||||
terminal?.dispose();
|
terminal?.dispose();
|
||||||
fitAddon?.dispose();
|
fitAddon?.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
};
|
};
|
||||||
}, [resize, workspaceId, shellId]);
|
// Depend on `resize.mutate` (the stable function from useMutation)
|
||||||
|
// rather than the whole `resize` object, so this effect doesn't
|
||||||
|
// re-run on every render if anything else in the mutation result
|
||||||
|
// changes by reference.
|
||||||
|
}, [sendResize, workspaceId, shellId]);
|
||||||
|
|
||||||
// Reset per-workspace state and wire the active socket to xterm.
|
// Reset per-workspace state and wire the active socket to xterm.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,7 +257,7 @@ export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={containerRef} className="min-h-0 flex-1 p-2" />
|
<div ref={containerRef} className="min-h-0 flex-1 px-2 pb-8" />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface AcpStatus {
|
export interface AcpStatus {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -34,6 +41,25 @@ export interface AcpPromptResponse {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AcpStreamEvent =
|
||||||
|
| { type: "chunk"; messageId?: string; text: string }
|
||||||
|
| { type: "complete"; stopReason: string }
|
||||||
|
| { type: "error"; error: string };
|
||||||
|
|
||||||
|
export type AcpStreamStatus =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "open"
|
||||||
|
| "closed"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface AcpStream {
|
||||||
|
status: AcpStreamStatus;
|
||||||
|
open: (content: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
onEvent: (cb: (event: AcpStreamEvent) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
function acpStatusQueryKey(workspaceId: string) {
|
function acpStatusQueryKey(workspaceId: string) {
|
||||||
return ["workspaces", workspaceId, "acp", "status"] as const;
|
return ["workspaces", workspaceId, "acp", "status"] as const;
|
||||||
}
|
}
|
||||||
@@ -147,3 +173,190 @@ export function useAcpCancel(): UseMutationResult<void, Error, string> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAcpStreamEvent(data: unknown): data is AcpStreamEvent {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ev = data as Record<string, unknown>;
|
||||||
|
if (ev.type === "chunk") {
|
||||||
|
return typeof ev.text === "string";
|
||||||
|
}
|
||||||
|
if (ev.type === "complete") {
|
||||||
|
return typeof ev.stopReason === "string";
|
||||||
|
}
|
||||||
|
if (ev.type === "error") {
|
||||||
|
return typeof ev.error === "string";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcpStream(workspaceId: string | null): AcpStream {
|
||||||
|
const [status, setStatus] = useState<AcpStreamStatus>("idle");
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
useEffect(() => {
|
||||||
|
statusRef.current = status;
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
|
const intentionalCloseRef = useRef(false);
|
||||||
|
const serverCleanCloseRef = useRef(false);
|
||||||
|
const terminalEventRef = useRef(false);
|
||||||
|
const pendingContentRef = useRef<string | null>(null);
|
||||||
|
const callbacksRef = useRef(new Set<(event: AcpStreamEvent) => void>());
|
||||||
|
|
||||||
|
const clearReconnectTimeout = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSocket = useCallback(() => {
|
||||||
|
intentionalCloseRef.current = true;
|
||||||
|
clearReconnectTimeout();
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus("closed");
|
||||||
|
}, [clearReconnectTimeout]);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any existing socket before starting a fresh prompt stream.
|
||||||
|
if (socketRef.current) {
|
||||||
|
intentionalCloseRef.current = true;
|
||||||
|
const socket = socketRef.current;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
intentionalCloseRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimeout();
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
serverCleanCloseRef.current = false;
|
||||||
|
terminalEventRef.current = false;
|
||||||
|
pendingContentRef.current = content;
|
||||||
|
|
||||||
|
const openSocket = () => {
|
||||||
|
const wsUrl =
|
||||||
|
(window.location.protocol === "https:" ? "wss:" : "ws:") +
|
||||||
|
"//" +
|
||||||
|
window.location.host +
|
||||||
|
"/api/workspaces/" +
|
||||||
|
encodeURIComponent(workspaceId) +
|
||||||
|
"/acp/stream";
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
socketRef.current = ws;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
serverCleanCloseRef.current = false;
|
||||||
|
terminalEventRef.current = false;
|
||||||
|
setStatus("open");
|
||||||
|
const contentToSend = pendingContentRef.current;
|
||||||
|
if (contentToSend !== null) {
|
||||||
|
ws.send(JSON.stringify({ type: "prompt", content: contentToSend }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(
|
||||||
|
typeof event.data === "string" ? event.data : "",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAcpStreamEvent(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callbacksRef.current.forEach((cb) => cb(data));
|
||||||
|
if (data.type === "complete" || data.type === "error") {
|
||||||
|
terminalEventRef.current = true;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
socketRef.current = null;
|
||||||
|
if (
|
||||||
|
(event.code === 1000 || event.code === 1001) &&
|
||||||
|
!intentionalCloseRef.current
|
||||||
|
) {
|
||||||
|
serverCleanCloseRef.current = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
intentionalCloseRef.current ||
|
||||||
|
terminalEventRef.current ||
|
||||||
|
serverCleanCloseRef.current
|
||||||
|
) {
|
||||||
|
setStatus("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attempts = reconnectAttemptsRef.current;
|
||||||
|
if (attempts < 3) {
|
||||||
|
setStatus("connecting");
|
||||||
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
openSocket();
|
||||||
|
}, Math.pow(2, attempts) * 1000);
|
||||||
|
reconnectAttemptsRef.current = attempts + 1;
|
||||||
|
} else {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// The browser fires onclose after an error; handle retries there.
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openSocket();
|
||||||
|
},
|
||||||
|
[workspaceId, clearReconnectTimeout],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearReconnectTimeout();
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [workspaceId, clearReconnectTimeout]);
|
||||||
|
|
||||||
|
const onEvent = useCallback(
|
||||||
|
(cb: (event: AcpStreamEvent) => void) => {
|
||||||
|
callbacksRef.current.add(cb);
|
||||||
|
return () => {
|
||||||
|
callbacksRef.current.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
closeSocket();
|
||||||
|
}, [closeSocket]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ status, open, close, onEvent }),
|
||||||
|
[status, open, close, onEvent],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export function useFileRead(
|
|||||||
queryKey: fileReadQueryKey(workspaceId ?? "", path ?? ""),
|
queryKey: fileReadQueryKey(workspaceId ?? "", path ?? ""),
|
||||||
queryFn: () => fetchFileRead(workspaceId!, path!),
|
queryFn: () => fetchFileRead(workspaceId!, path!),
|
||||||
enabled: !!workspaceId && !!path,
|
enabled: !!workspaceId && !!path,
|
||||||
|
// Poll so external edits (the agent, another tool) are visible in
|
||||||
|
// the editor without requiring a manual refresh.
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-48
@@ -188,14 +188,19 @@ function useProcessActionMutation(
|
|||||||
action: "start" | "stop" | "restart",
|
action: "start" | "stop" | "restart",
|
||||||
): UseMutationResult<void, Error, string> {
|
): UseMutationResult<void, Error, string> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
const mutationFn = useCallback(
|
||||||
mutationFn: (workspaceId: string) => postProcessAction(workspaceId, action),
|
(workspaceId: string) => postProcessAction(workspaceId, action),
|
||||||
onSuccess: (_, workspaceId) => {
|
[action],
|
||||||
|
);
|
||||||
|
const onSuccess = useCallback(
|
||||||
|
(_: void, workspaceId: string) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: processStatusQueryKey(workspaceId),
|
queryKey: processStatusQueryKey(workspaceId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
[queryClient],
|
||||||
|
);
|
||||||
|
return useMutation({ mutationFn, onSuccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,69 +220,72 @@ export function useShellStart(
|
|||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): UseMutationResult<ShellInfo, Error, void> {
|
): UseMutationResult<ShellInfo, Error, void> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
const mutationFn = useCallback(async () => {
|
||||||
mutationFn: async () => {
|
if (!workspaceId) {
|
||||||
if (!workspaceId) {
|
throw new Error("workspace id required");
|
||||||
throw new Error("workspace id required");
|
}
|
||||||
}
|
return postShellStart(workspaceId);
|
||||||
return postShellStart(workspaceId);
|
}, [workspaceId]);
|
||||||
},
|
const onSuccess = useCallback(() => {
|
||||||
onSuccess: () => {
|
if (!workspaceId) return;
|
||||||
if (!workspaceId) return;
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: shellListQueryKey(workspaceId),
|
||||||
queryKey: shellListQueryKey(workspaceId),
|
});
|
||||||
});
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
});
|
||||||
});
|
}, [queryClient, workspaceId]);
|
||||||
},
|
return useMutation({ mutationFn, onSuccess });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShellStop(
|
export function useShellStop(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): UseMutationResult<void, Error, { shellId: string }> {
|
): UseMutationResult<void, Error, { shellId: string }> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
const mutationFn = useCallback(
|
||||||
mutationFn: async ({ shellId }) => {
|
async ({ shellId }: { shellId: string }) => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new Error("workspace id required");
|
throw new Error("workspace id required");
|
||||||
}
|
}
|
||||||
return postShellStop(workspaceId, shellId);
|
return postShellStop(workspaceId, shellId);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
[workspaceId],
|
||||||
if (!workspaceId) return;
|
);
|
||||||
queryClient.invalidateQueries({
|
const onSuccess = useCallback(() => {
|
||||||
queryKey: shellListQueryKey(workspaceId),
|
if (!workspaceId) return;
|
||||||
});
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: shellListQueryKey(workspaceId),
|
||||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
});
|
||||||
});
|
queryClient.invalidateQueries({
|
||||||
},
|
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||||
});
|
});
|
||||||
|
}, [queryClient, workspaceId]);
|
||||||
|
return useMutation({ mutationFn, onSuccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShellRestart(
|
export function useShellRestart(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): UseMutationResult<ShellInfo, Error, { shellId: string }> {
|
): UseMutationResult<ShellInfo, Error, { shellId: string }> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
const mutationFn = useCallback(
|
||||||
mutationFn: async ({ shellId }) => {
|
async ({ shellId }: { shellId: string }) => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new Error("workspace id required");
|
throw new Error("workspace id required");
|
||||||
}
|
}
|
||||||
return postShellRestart(workspaceId, shellId);
|
return postShellRestart(workspaceId, shellId);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
[workspaceId],
|
||||||
if (!workspaceId) return;
|
);
|
||||||
queryClient.invalidateQueries({
|
const onSuccess = useCallback(() => {
|
||||||
queryKey: shellListQueryKey(workspaceId),
|
if (!workspaceId) return;
|
||||||
});
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: shellListQueryKey(workspaceId),
|
||||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
});
|
||||||
});
|
queryClient.invalidateQueries({
|
||||||
},
|
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||||
});
|
});
|
||||||
|
}, [queryClient, workspaceId]);
|
||||||
|
return useMutation({ mutationFn, onSuccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postShellResize(
|
async function postShellResize(
|
||||||
@@ -303,14 +311,21 @@ export function useShellResize(
|
|||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
shellId: string | null,
|
shellId: string | null,
|
||||||
): UseMutationResult<void, Error, { cols: number; rows: number }> {
|
): UseMutationResult<void, Error, { cols: number; rows: number }> {
|
||||||
return useMutation({
|
// Stable mutationFn: without useCallback, a fresh closure is created on
|
||||||
mutationFn: async ({ cols, rows }) => {
|
// every render, which makes useMutation's returned object unstable. That
|
||||||
|
// ripples into useEffect dep arrays in TerminalPanel and causes the
|
||||||
|
// terminal to be torn down and recreated on every render — which fires
|
||||||
|
// onResize in a tight loop and floods /shell/resize.
|
||||||
|
const mutationFn = useCallback(
|
||||||
|
async ({ cols, rows }: { cols: number; rows: number }) => {
|
||||||
if (!workspaceId || !shellId) {
|
if (!workspaceId || !shellId) {
|
||||||
throw new Error("workspace id and shell id required");
|
throw new Error("workspace id and shell id required");
|
||||||
}
|
}
|
||||||
return postShellResize(workspaceId, shellId, cols, rows);
|
return postShellResize(workspaceId, shellId, cols, rows);
|
||||||
},
|
},
|
||||||
});
|
[workspaceId, shellId],
|
||||||
|
);
|
||||||
|
return useMutation({ mutationFn });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProcessWebSocketStatus =
|
export type ProcessWebSocketStatus =
|
||||||
|
|||||||
Reference in New Issue
Block a user