Detailed changes
@@ -66,6 +66,7 @@ func TS() *go2ts.Go2TS {
generator.AddMultiple(
apiMessageForTS{},
streamResponseForTS{},
+ conversationWithStateForTS{},
)
// Generate clean nominal types
@@ -87,8 +88,24 @@ type apiMessageForTS struct {
EndOfTurn *bool `json:"end_of_turn,omitempty"`
}
+type conversationStateForTS struct {
+ ConversationID string `json:"conversation_id"`
+ Working bool `json:"working"`
+}
+
+type conversationWithStateForTS struct {
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ UserInitiated bool `json:"user_initiated"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Cwd *string `json:"cwd"`
+ Archived bool `json:"archived"`
+ Working bool `json:"working"`
+}
+
type streamResponseForTS struct {
- Messages []apiMessageForTS `json:"messages"`
- Conversation generated.Conversation `json:"conversation"`
- AgentWorking *bool `json:"agent_working,omitempty"`
+ Messages []apiMessageForTS `json:"messages"`
+ Conversation generated.Conversation `json:"conversation"`
+ ConversationState *conversationStateForTS `json:"conversation_state,omitempty"`
}
@@ -1,104 +0,0 @@
-package server
-
-import (
- "fmt"
- "testing"
-
- "shelley.exe.dev/db"
-)
-
-func TestAgentWorking(t *testing.T) {
- tests := []struct {
- name string
- messages []APIMessage
- want bool
- }{
- {
- name: "empty messages",
- messages: []APIMessage{},
- want: false,
- },
- {
- name: "agent with end_of_turn true",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: truePtr},
- },
- want: false,
- },
- {
- name: "agent with end_of_turn false",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: falsePtr},
- },
- want: true,
- },
- {
- name: "agent with end_of_turn nil",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: nil},
- },
- want: true,
- },
- {
- name: "error message",
- messages: []APIMessage{
- {Type: string(db.MessageTypeError)},
- },
- want: false,
- },
- {
- name: "agent end_of_turn then tool message means working",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: truePtr},
- {Type: string(db.MessageTypeTool)},
- },
- want: true,
- },
- {
- name: "gitinfo after agent end_of_turn should NOT indicate working",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: truePtr},
- {Type: string(db.MessageTypeGitInfo)},
- },
- want: false,
- },
- {
- name: "multiple gitinfo after agent end_of_turn should NOT indicate working",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: truePtr},
- {Type: string(db.MessageTypeGitInfo)},
- {Type: string(db.MessageTypeGitInfo)},
- },
- want: false,
- },
- {
- name: "gitinfo after agent not end_of_turn should indicate working",
- messages: []APIMessage{
- {Type: string(db.MessageTypeAgent), EndOfTurn: falsePtr},
- {Type: string(db.MessageTypeGitInfo)},
- },
- want: true,
- },
- {
- name: "only gitinfo messages",
- messages: []APIMessage{
- {Type: string(db.MessageTypeGitInfo)},
- {Type: string(db.MessageTypeGitInfo)},
- },
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := agentWorking(tt.messages)
- if got == nil || *got != tt.want {
- gotVal := "nil"
- if got != nil {
- gotVal = fmt.Sprintf("%v", *got)
- }
- t.Errorf("agentWorking() = %v, want %v", gotVal, tt.want)
- }
- })
- }
-}
@@ -0,0 +1,147 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/llm"
+ "shelley.exe.dev/loop"
+)
+
+// responseRecorderWithClose wraps httptest.ResponseRecorder to support CloseNotify
+type responseRecorderWithClose struct {
+ *httptest.ResponseRecorder
+ closeNotify chan bool
+}
+
+func newResponseRecorderWithClose() *responseRecorderWithClose {
+ return &responseRecorderWithClose{
+ ResponseRecorder: httptest.NewRecorder(),
+ closeNotify: make(chan bool, 1),
+ }
+}
+
+func (r *responseRecorderWithClose) CloseNotify() <-chan bool {
+ return r.closeNotify
+}
+
+func (r *responseRecorderWithClose) Close() {
+ select {
+ case r.closeNotify <- true:
+ default:
+ }
+}
+
+// TestConversationStateAfterServerRestart verifies that when a conversation is
+// loaded after a server restart (new manager created), the agent is correctly
+// reported as not working since the loop isn't running.
+func TestConversationStateAfterServerRestart(t *testing.T) {
+ database, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ ctx := context.Background()
+
+ // Create a conversation with some messages (simulating previous activity)
+ conv, err := database.CreateConversation(ctx, nil, true, nil)
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+
+ // Add a user message
+ userMsg := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}},
+ }
+ _, err = database.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeUser,
+ LLMData: userMsg,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create user message: %v", err)
+ }
+
+ // Add an agent message (without end_of_turn to simulate mid-conversation)
+ agentMsg := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hi there!"}},
+ EndOfTurn: false,
+ }
+ _, err = database.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: conv.ConversationID,
+ Type: db.MessageTypeAgent,
+ LLMData: agentMsg,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create agent message: %v", err)
+ }
+
+ // Create a NEW server (simulating server restart - no active managers)
+ predictableService := loop.NewPredictableService()
+ llmManager := &testLLMManager{service: predictableService}
+ toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+ server := NewServer(database, llmManager, toolSetConfig, nil, true, "", "predictable", "", nil)
+
+ mux := http.NewServeMux()
+ server.RegisterRoutes(mux)
+
+ // Make a streaming request with a context that cancels after we read the first message
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ req := httptest.NewRequest("GET", "/api/conversation/"+conv.ConversationID+"/stream", nil).WithContext(ctx)
+ req.Header.Set("Accept", "text/event-stream")
+
+ w := newResponseRecorderWithClose()
+
+ // Run handler in goroutine and close connection after getting first response
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ mux.ServeHTTP(w, req)
+ }()
+
+ // Wait for some data or timeout
+ time.Sleep(500 * time.Millisecond)
+ w.Close()
+ cancel()
+
+ // Wait for handler to finish
+ <-done
+
+ // Parse the first SSE message
+ body := w.Body.String()
+ if !strings.HasPrefix(body, "data: ") {
+ t.Fatalf("Expected SSE data, got: %s", body)
+ }
+
+ jsonData := strings.TrimPrefix(strings.Split(body, "\n")[0], "data: ")
+ var response StreamResponse
+ if err := json.Unmarshal([]byte(jsonData), &response); err != nil {
+ t.Fatalf("Failed to parse response: %v", err)
+ }
+
+ // Verify conversation state shows agent is NOT working
+ // (because after server restart, no loop is running)
+ if response.ConversationState == nil {
+ t.Fatal("Expected ConversationState in response")
+ }
+ if response.ConversationState.ConversationID != conv.ConversationID {
+ t.Errorf("Expected ConversationID %s, got %s", conv.ConversationID, response.ConversationState.ConversationID)
+ }
+ if response.ConversationState.Working {
+ t.Error("Expected Working=false after server restart (no active loop)")
+ }
+
+ // Verify messages were loaded
+ if len(response.Messages) != 2 {
+ t.Errorf("Expected 2 messages, got %d", len(response.Messages))
+ }
+}
@@ -41,10 +41,18 @@ type ConversationManager struct {
hydrated bool
hasConversationEvents bool
cwd string // working directory for tools
+
+ // agentWorking tracks whether the agent is currently working.
+ // This is explicitly managed and broadcast to subscribers when it changes.
+ agentWorking bool
+
+ // onStateChange is called when the conversation state changes.
+ // This allows the server to broadcast state changes to all subscribers.
+ onStateChange func(state ConversationState)
}
// NewConversationManager constructs a manager with dependencies but defers hydration until needed.
-func NewConversationManager(conversationID string, database *db.DB, baseLogger *slog.Logger, toolSetConfig claudetool.ToolSetConfig, recordMessage loop.MessageRecordFunc) *ConversationManager {
+func NewConversationManager(conversationID string, database *db.DB, baseLogger *slog.Logger, toolSetConfig claudetool.ToolSetConfig, recordMessage loop.MessageRecordFunc, onStateChange func(ConversationState)) *ConversationManager {
logger := baseLogger
if logger == nil {
logger = slog.Default()
@@ -59,7 +67,36 @@ func NewConversationManager(conversationID string, database *db.DB, baseLogger *
logger: logger,
toolSetConfig: toolSetConfig,
subpub: subpub.New[StreamResponse](),
+ onStateChange: onStateChange,
+ }
+}
+
+// SetAgentWorking updates the agent working state and notifies the server to broadcast.
+func (cm *ConversationManager) SetAgentWorking(working bool) {
+ cm.mu.Lock()
+ if cm.agentWorking == working {
+ cm.mu.Unlock()
+ return
}
+ cm.agentWorking = working
+ onStateChange := cm.onStateChange
+ convID := cm.conversationID
+ cm.mu.Unlock()
+
+ cm.logger.Debug("agent working state changed", "working", working)
+ if onStateChange != nil {
+ onStateChange(ConversationState{
+ ConversationID: convID,
+ Working: working,
+ })
+ }
+}
+
+// IsAgentWorking returns the current agent working state.
+func (cm *ConversationManager) IsAgentWorking() bool {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ return cm.agentWorking
}
// Hydrate loads conversation state from the database, generating a system prompt if missing.
@@ -158,6 +195,9 @@ func (cm *ConversationManager) AcceptUserMessage(ctx context.Context, service ll
loopInstance.QueueUserMessage(message)
+ // Mark agent as working - we just queued work for the loop
+ cm.SetAgentWorking(true)
+
return isFirst, nil
}
@@ -480,6 +520,9 @@ func (cm *ConversationManager) CancelConversation(ctx context.Context) error {
return fmt.Errorf("failed to record end turn message: %w", err)
}
+ // Mark agent as not working
+ cm.SetAgentWorking(false)
+
cm.mu.Lock()
cm.loopCancel = nil
cm.loopCtx = nil
@@ -557,7 +600,6 @@ func (cm *ConversationManager) notifyGitStateChange(ctx context.Context, msg *ge
streamData := StreamResponse{
Messages: apiMessages,
Conversation: conversation,
- AgentWorking: falsePtr, // Gitinfo is recorded at end of turn, agent is done
}
cm.subpub.Publish(msg.SequenceID, streamData)
}
@@ -520,8 +520,20 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
return
}
+ // Get working states for all active conversations
+ workingStates := s.getWorkingConversations()
+
+ // Build response with working state included
+ result := make([]ConversationWithState, len(conversations))
+ for i, conv := range conversations {
+ result[i] = ConversationWithState{
+ Conversation: conv,
+ Working: workingStates[conv.ConversationID],
+ }
+ }
+
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(conversations)
+ json.NewEncoder(w).Encode(result)
}
// conversationMux returns a mux for /api/conversation/<id>/* routes
@@ -593,9 +605,9 @@ func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request, c
w.Header().Set("Content-Type", "application/json")
apiMessages := toAPIMessages(messages)
json.NewEncoder(w).Encode(StreamResponse{
- Messages: apiMessages,
- Conversation: conversation,
- AgentWorking: agentWorking(apiMessages),
+ Messages: apiMessages,
+ Conversation: conversation,
+ // ConversationState is sent via the streaming endpoint, not on initial load
ContextWindowSize: calculateContextWindowSize(apiMessages),
})
}
@@ -863,25 +875,28 @@ func (s *Server) handleStreamConversation(w http.ResponseWriter, r *http.Request
return
}
- // Send current messages and conversation data
+ // Get or create conversation manager to access working state
+ manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+ if err != nil {
+ s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+ return
+ }
+
+ // Send current messages, conversation data, and conversation state
apiMessages := toAPIMessages(messages)
streamData := StreamResponse{
- Messages: apiMessages,
- Conversation: conversation,
- AgentWorking: agentWorking(apiMessages),
+ Messages: apiMessages,
+ Conversation: conversation,
+ ConversationState: &ConversationState{
+ ConversationID: conversationID,
+ Working: manager.IsAgentWorking(),
+ },
ContextWindowSize: calculateContextWindowSize(apiMessages),
}
data, _ := json.Marshal(streamData)
fmt.Fprintf(w, "data: %s\n\n", data)
w.(http.Flusher).Flush()
- // Get or create conversation manager
- manager, err := s.getOrCreateConversationManager(ctx, conversationID)
- if err != nil {
- s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
- return
- }
-
// Subscribe to new messages after the last one we sent
last := int64(-1)
if len(messages) > 0 {
@@ -41,11 +41,24 @@ type APIMessage struct {
EndOfTurn *bool `json:"end_of_turn,omitempty"`
}
+// ConversationState represents the current state of a conversation.
+// This is broadcast to all subscribers whenever the state changes.
+type ConversationState struct {
+ ConversationID string `json:"conversation_id"`
+ Working bool `json:"working"`
+}
+
+// ConversationWithState combines a conversation with its working state.
+type ConversationWithState struct {
+ generated.Conversation
+ Working bool `json:"working"`
+}
+
// StreamResponse represents the response format for conversation streaming
type StreamResponse struct {
Messages []APIMessage `json:"messages"`
Conversation generated.Conversation `json:"conversation"`
- AgentWorking *bool `json:"agent_working,omitempty"`
+ ConversationState *ConversationState `json:"conversation_state,omitempty"`
ContextWindowSize uint64 `json:"context_window_size,omitempty"`
// ConversationListUpdate is set when another conversation in the list changed
ConversationListUpdate *ConversationListUpdate `json:"conversation_list_update,omitempty"`
@@ -149,69 +162,12 @@ func calculateContextWindowSize(messages []APIMessage) uint64 {
return 0
}
-var (
- truePtr = ptr(true)
- falsePtr = ptr(false)
-)
-
-func ptr[T any](v T) *T { return &v }
-
-func agentWorking(messages []APIMessage) *bool {
- if len(messages) == 0 {
- return falsePtr
- }
-
- // Find the last non-gitinfo message (gitinfo messages are passive notifications)
- lastIdx := len(messages) - 1
- for lastIdx >= 0 && messages[lastIdx].Type == string(db.MessageTypeGitInfo) {
- lastIdx--
- }
- if lastIdx < 0 {
- return falsePtr
- }
- last := messages[lastIdx]
-
- // If the last message is an error, agent is not working
- if last.Type == string(db.MessageTypeError) {
- return falsePtr
- }
-
- if last.Type == string(db.MessageTypeAgent) {
- if last.EndOfTurn == nil {
- return truePtr
- }
- if *last.EndOfTurn {
- return falsePtr
- }
- return truePtr
- }
-
- for i := lastIdx; i >= 0; i-- {
- msg := messages[i]
- if msg.Type != string(db.MessageTypeAgent) {
- continue
- }
- // Agent ended turn, but newer non-agent messages exist, so agent is working again.
- return truePtr
- }
-
- // No agent message found yet but conversation has activity, assume agent is working.
- return truePtr
-}
-
-// isEndOfTurn checks if a database message represents end of turn
-func isEndOfTurn(msg *generated.Message) bool {
+// isAgentEndOfTurn checks if a message is an agent message with end_of_turn=true.
+// This indicates the agent loop has finished processing.
+func isAgentEndOfTurn(msg *generated.Message) bool {
if msg == nil {
return false
}
- // Error messages end the turn
- if msg.Type == string(db.MessageTypeError) {
- return true
- }
- // Gitinfo messages always come at end of turn (after a commit)
- if msg.Type == string(db.MessageTypeGitInfo) {
- return true
- }
// Only agent messages can have end_of_turn
if msg.Type != string(db.MessageTypeAgent) {
return false
@@ -480,7 +436,11 @@ func (s *Server) getOrCreateConversationManager(ctx context.Context, conversatio
return s.recordMessage(ctx, conversationID, message, usage)
}
- manager := NewConversationManager(conversationID, s.db, s.logger, s.toolSetConfig, recordMessage)
+ onStateChange := func(state ConversationState) {
+ s.publishConversationState(state)
+ }
+
+ manager := NewConversationManager(conversationID, s.db, s.logger, s.toolSetConfig, recordMessage, onStateChange)
if err := manager.Hydrate(ctx); err != nil {
return nil, err
}
@@ -682,15 +642,15 @@ func (s *Server) notifySubscribersNewMessage(ctx context.Context, conversationID
// Convert the single new message to API format
apiMessages := toAPIMessages([]generated.Message{*newMsg})
- // Publish only the new message
- agentWorking := falsePtr
- if !isEndOfTurn(newMsg) {
- agentWorking = truePtr
+ // Update agent working state based on message type
+ if isAgentEndOfTurn(newMsg) {
+ manager.SetAgentWorking(false)
}
+
+ // Publish only the new message
streamData := StreamResponse{
Messages: apiMessages,
Conversation: conversation,
- AgentWorking: agentWorking,
// ContextWindowSize: 0 for messages without usage data (user/tool messages).
// With omitempty, 0 is omitted from JSON, so the UI keeps its cached value.
// Only agent messages have usage data, so context window updates when they arrive.
@@ -721,6 +681,35 @@ func (s *Server) publishConversationListUpdate(update ConversationListUpdate) {
}
}
+// publishConversationState broadcasts a conversation state update to ALL active
+// conversation streams. This allows clients to see the working state of other conversations.
+func (s *Server) publishConversationState(state ConversationState) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Broadcast to all active conversation managers
+ for _, manager := range s.activeConversations {
+ streamData := StreamResponse{
+ ConversationState: &state,
+ }
+ manager.subpub.Broadcast(streamData)
+ }
+}
+
+// getWorkingConversations returns a map of conversation IDs that are currently working.
+func (s *Server) getWorkingConversations() map[string]bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ working := make(map[string]bool)
+ for id, manager := range s.activeConversations {
+ if manager.IsAgentWorking() {
+ working[id] = true
+ }
+ }
+ return working
+}
+
// Cleanup removes inactive conversation managers
func (s *Server) Cleanup() {
s.mu.Lock()
@@ -644,7 +644,8 @@ func TestSSEIncrementalUpdates(t *testing.T) {
defer client1.Body.Close()
// Read initial response from client1 (should contain the first message)
- buf1 := make([]byte, 2048)
+ // Buffer must be large enough to hold the full response including system prompt
+ buf1 := make([]byte, 32768)
n1, err := client1.Body.Read(buf1)
if err != nil && err != io.EOF {
t.Fatalf("Failed to read from client1: %v", err)
@@ -678,7 +679,7 @@ func TestSSEIncrementalUpdates(t *testing.T) {
defer client2.Body.Close()
// Read response from client2 (should contain both messages since it's a new client)
- buf2 := make([]byte, 2048)
+ buf2 := make([]byte, 32768)
n2, err := client2.Body.Read(buf2)
if err != nil && err != io.EOF {
t.Fatalf("Failed to read from client2: %v", err)
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
import ChatInterface from "./components/ChatInterface";
import ConversationDrawer from "./components/ConversationDrawer";
import CommandPalette from "./components/CommandPalette";
-import { Conversation, ConversationListUpdate } from "./types";
+import { Conversation, ConversationWithState, ConversationListUpdate } from "./types";
import { api } from "./services/api";
// Check if a slug is a generated ID (format: cXXXX where X is alphanumeric)
@@ -59,7 +59,7 @@ function updatePageTitle(conversation: Conversation | undefined) {
}
function App() {
- const [conversations, setConversations] = useState<Conversation[]>([]);
+ const [conversations, setConversations] = useState<ConversationWithState[]>([]);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerCollapsed, setDrawerCollapsed] = useState(false);
@@ -127,13 +127,17 @@ function App() {
);
if (existingIndex >= 0) {
- // Update existing conversation in place (don't re-sort to avoid distracting jumps)
+ // Update existing conversation in place, preserving working state
+ // (working state is updated separately via conversation_state)
const updated = [...prev];
- updated[existingIndex] = update.conversation!;
+ updated[existingIndex] = {
+ ...update.conversation!,
+ working: prev[existingIndex].working,
+ };
return updated;
} else {
- // Add new conversation at the top
- return [update.conversation!, ...prev];
+ // Add new conversation at the top (not working by default)
+ return [{ ...update.conversation!, working: false }, ...prev];
}
});
} else if (update.type === "delete" && update.conversation_id) {
@@ -141,6 +145,20 @@ function App() {
}
}, []);
+ // Handle conversation state updates (working state changes)
+ const handleConversationStateUpdate = useCallback(
+ (state: { conversation_id: string; working: boolean }) => {
+ setConversations((prev) =>
+ prev.map((conv) =>
+ conv.conversation_id === state.conversation_id
+ ? { ...conv, working: state.working }
+ : conv,
+ ),
+ );
+ },
+ [],
+ );
+
// Update page title and URL when conversation changes
useEffect(() => {
const currentConv = conversations.find(
@@ -193,7 +211,9 @@ function App() {
const updateConversation = (updatedConversation: Conversation) => {
setConversations((prev) =>
prev.map((conv) =>
- conv.conversation_id === updatedConversation.conversation_id ? updatedConversation : conv,
+ conv.conversation_id === updatedConversation.conversation_id
+ ? { ...updatedConversation, working: conv.working }
+ : conv,
),
);
};
@@ -208,14 +228,18 @@ function App() {
};
const handleConversationUnarchived = (conversation: Conversation) => {
- // Add the unarchived conversation back to the list
- setConversations((prev) => [conversation, ...prev]);
+ // Add the unarchived conversation back to the list (not working by default)
+ setConversations((prev) => [{ ...conversation, working: false }, ...prev]);
};
const handleConversationRenamed = (conversation: Conversation) => {
- // Update the conversation in the list with the new slug
+ // Update the conversation in the list with the new slug, preserving working state
setConversations((prev) =>
- prev.map((c) => (c.conversation_id === conversation.conversation_id ? conversation : c)),
+ prev.map((c) =>
+ c.conversation_id === conversation.conversation_id
+ ? { ...conversation, working: c.working }
+ : c,
+ ),
);
};
@@ -294,6 +318,7 @@ function App() {
currentConversation={currentConversation}
onConversationUpdate={updateConversation}
onConversationListUpdate={handleConversationListUpdate}
+ onConversationStateUpdate={handleConversationStateUpdate}
onFirstMessage={handleFirstMessage}
mostRecentCwd={mostRecentCwd}
isDrawerCollapsed={drawerCollapsed}
@@ -353,6 +353,11 @@ function AnimatedWorkingStatus() {
);
}
+interface ConversationStateUpdate {
+ conversation_id: string;
+ working: boolean;
+}
+
interface ChatInterfaceProps {
conversationId: string | null;
onOpenDrawer: () => void;
@@ -360,6 +365,7 @@ interface ChatInterfaceProps {
currentConversation?: Conversation;
onConversationUpdate?: (conversation: Conversation) => void;
onConversationListUpdate?: (update: ConversationListUpdate) => void;
+ onConversationStateUpdate?: (state: ConversationStateUpdate) => void;
onFirstMessage?: (message: string, model: string, cwd?: string) => Promise<void>;
mostRecentCwd?: string | null;
isDrawerCollapsed?: boolean;
@@ -374,6 +380,7 @@ function ChatInterface({
currentConversation,
onConversationUpdate,
onConversationListUpdate,
+ onConversationStateUpdate,
onFirstMessage,
mostRecentCwd,
isDrawerCollapsed,
@@ -578,7 +585,8 @@ function ChatInterface({
setError(null);
const response = await api.getConversation(conversationId);
setMessages(response.messages ?? []);
- setAgentWorking(Boolean(response.agent_working));
+ // ConversationState is sent via the streaming endpoint, not on initial load
+ // We don't update agentWorking here - the stream will provide the current state
// Always update context window size when loading a conversation.
// If omitted from response (due to omitempty when 0), default to 0.
setContextWindowSize(response.context_window_size ?? 0);
@@ -638,8 +646,16 @@ function ChatInterface({
onConversationListUpdate(streamResponse.conversation_list_update);
}
- if (typeof streamResponse.agent_working === "boolean") {
- setAgentWorking(streamResponse.agent_working);
+ // Handle conversation state updates (explicit from server)
+ if (streamResponse.conversation_state) {
+ // Update the conversations list with new working state
+ if (onConversationStateUpdate) {
+ onConversationStateUpdate(streamResponse.conversation_state);
+ }
+ // Update local state if this is for our conversation
+ if (streamResponse.conversation_state.conversation_id === conversationId) {
+ setAgentWorking(streamResponse.conversation_state.working);
+ }
}
if (typeof streamResponse.context_window_size === "number") {
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
-import { Conversation } from "../types";
+import { Conversation, ConversationWithState } from "../types";
import { api } from "../services/api";
interface ConversationDrawerProps {
@@ -7,7 +7,7 @@ interface ConversationDrawerProps {
isCollapsed: boolean;
onClose: () => void;
onToggleCollapse: () => void;
- conversations: Conversation[];
+ conversations: ConversationWithState[];
currentConversationId: string | null;
onSelectConversation: (id: string) => void;
onNewConversation: () => void;
@@ -279,33 +279,53 @@ function ConversationDrawer({
style={{ cursor: showArchived ? "default" : "pointer" }}
>
<div style={{ flex: 1, minWidth: 0 }}>
- {editingId === conversation.conversation_id ? (
- <input
- ref={renameInputRef}
- type="text"
- value={editingSlug}
- onChange={(e) => setEditingSlug(e.target.value)}
- onBlur={() => handleRename(conversation.conversation_id)}
- onKeyDown={(e) => handleRenameKeyDown(e, conversation.conversation_id)}
- onClick={(e) => e.stopPropagation()}
- autoFocus
- className="conversation-title"
- style={{
- width: "100%",
- background: "transparent",
- border: "none",
- borderBottom: "1px solid var(--text-secondary)",
- outline: "none",
- padding: 0,
- font: "inherit",
- color: "inherit",
- }}
- />
- ) : (
- <div className="conversation-title">
- {getConversationPreview(conversation)}
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
+ <div style={{ flex: 1, minWidth: 0 }}>
+ {editingId === conversation.conversation_id ? (
+ <input
+ ref={renameInputRef}
+ type="text"
+ value={editingSlug}
+ onChange={(e) => setEditingSlug(e.target.value)}
+ onBlur={() => handleRename(conversation.conversation_id)}
+ onKeyDown={(e) =>
+ handleRenameKeyDown(e, conversation.conversation_id)
+ }
+ onClick={(e) => e.stopPropagation()}
+ autoFocus
+ className="conversation-title"
+ style={{
+ width: "100%",
+ background: "transparent",
+ border: "none",
+ borderBottom: "1px solid var(--text-secondary)",
+ outline: "none",
+ padding: 0,
+ font: "inherit",
+ color: "inherit",
+ }}
+ />
+ ) : (
+ <div className="conversation-title">
+ {getConversationPreview(conversation)}
+ </div>
+ )}
</div>
- )}
+ {(conversation as ConversationWithState).working && (
+ <span
+ className="working-indicator"
+ title="Agent is working"
+ style={{
+ width: "8px",
+ height: "8px",
+ borderRadius: "50%",
+ backgroundColor: "var(--accent-color, #3b82f6)",
+ flexShrink: 0,
+ animation: "pulse 2s ease-in-out infinite",
+ }}
+ />
+ )}
+ </div>
<div className="conversation-meta">
<span className="conversation-date">
{formatDate(conversation.updated_at)}
@@ -315,100 +335,102 @@ function ConversationDrawer({
{formatCwdForDisplay(conversation.cwd)}
</span>
)}
- </div>
- </div>
- <div
- className="conversation-actions"
- style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
- >
- {showArchived ? (
- <>
- <button
- onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
- className="btn-icon-sm"
- title="Restore"
- aria-label="Restore conversation"
+ {!showArchived && (
+ <div
+ className="conversation-actions"
+ style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
>
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <button
+ onClick={(e) => handleStartRename(e, conversation)}
+ className="btn-icon-sm"
+ title="Rename"
+ aria-label="Rename conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
- />
- </svg>
- </button>
- <button
- onClick={(e) => handleDelete(e, conversation.conversation_id)}
- className="btn-icon-sm btn-danger"
- title="Delete permanently"
- aria-label="Delete conversation"
- >
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleArchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Archive"
+ aria-label="Archive conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
- />
- </svg>
- </button>
- </>
- ) : (
- <>
- <button
- onClick={(e) => handleStartRename(e, conversation)}
- className="btn-icon-sm"
- title="Rename"
- aria-label="Rename conversation"
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+ />
+ </svg>
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ {showArchived && (
+ <div
+ className="conversation-actions"
+ style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
+ >
+ <button
+ onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Restore"
+ aria-label="Restore conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
>
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
- >
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
- />
- </svg>
- </button>
- <button
- onClick={(e) => handleArchive(e, conversation.conversation_id)}
- className="btn-icon-sm"
- title="Archive"
- aria-label="Archive conversation"
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleDelete(e, conversation.conversation_id)}
+ className="btn-icon-sm btn-danger"
+ title="Delete permanently"
+ aria-label="Delete conversation"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
>
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
- >
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
- />
- </svg>
- </button>
- </>
- )}
- </div>
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
+ </div>
+ )}
</div>
);
})}
@@ -37,10 +37,26 @@ export interface ApiMessageForTS {
end_of_turn?: boolean | null;
}
+export interface ConversationStateForTS {
+ conversation_id: string;
+ working: boolean;
+}
+
export interface StreamResponseForTS {
messages: ApiMessageForTS[] | null;
conversation: Conversation;
- agent_working?: boolean | null;
+ conversation_state?: ConversationStateForTS | null;
+}
+
+export interface ConversationWithStateForTS {
+ conversation_id: string;
+ slug: string | null;
+ user_initiated: boolean;
+ created_at: string;
+ updated_at: string;
+ cwd: string | null;
+ archived: boolean;
+ working: boolean;
}
export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo";
@@ -1,5 +1,6 @@
import {
Conversation,
+ ConversationWithState,
StreamResponse,
ChatRequest,
GitDiffInfo,
@@ -16,7 +17,7 @@ class ApiService {
"X-Shelley-Request": "1",
};
- async getConversations(): Promise<Conversation[]> {
+ async getConversations(): Promise<ConversationWithState[]> {
const response = await fetch(`${this.baseUrl}/conversations`);
if (!response.ok) {
throw new Error(`Failed to get conversations: ${response.statusText}`);
@@ -24,7 +25,7 @@ class ApiService {
return response.json();
}
- async searchConversations(query: string): Promise<Conversation[]> {
+ async searchConversations(query: string): Promise<ConversationWithState[]> {
const params = new URLSearchParams({
q: query,
search_content: "true",
@@ -1,6 +1,7 @@
// Types for Shelley UI
import {
Conversation as GeneratedConversation,
+ ConversationWithStateForTS,
ApiMessageForTS,
StreamResponseForTS,
Usage as GeneratedUsage,
@@ -9,6 +10,7 @@ import {
// Re-export generated types
export type Conversation = GeneratedConversation;
+export type ConversationWithState = ConversationWithStateForTS;
export type Usage = GeneratedUsage;
export type MessageType = GeneratedMessageType;