diff --git a/pkg/agents/service.go b/pkg/agents/service.go index 231b910a54..9ded2a7d69 100644 --- a/pkg/agents/service.go +++ b/pkg/agents/service.go @@ -160,7 +160,7 @@ func (s *Service) SendMessage(ctx context.Context, organizationID, userID, sessi return nil, fmt.Errorf("message content is required") } - session, err := models.FindAgentSessionForUser(organizationID, userID, sessionID) + session, err := models.FindAgentSessionInOrg(organizationID, sessionID) if err != nil { return nil, err } @@ -218,8 +218,15 @@ func (s *Service) buildPreamble(session *models.AgentSession, organizationID, us session.CanvasID.String(), session.CanvasID.String(), ) + + // Inject user identity so the agent knows who is talking + userIdentity := fmt.Sprintf("current_user_id: %s", userID.String()) + if user, err := models.FindUnscopedUserByID(userID.String()); err == nil && user.Name != "" { + userIdentity = fmt.Sprintf("current_user_id: %s\ncurrent_user_name: %s", userID.String(), user.Name) + } + draftStatus := getDraftStatus(session.CanvasID) - return base + "\n\n" + modeInstructions(mode) + "\n\n" + draftStatus, nil + return base + "\n" + userIdentity + "\n\n" + modeInstructions(mode) + "\n\n" + draftStatus, nil } func (s *Service) enqueueStream(sessionID, organizationID, userID uuid.UUID) error { @@ -375,3 +382,52 @@ func sessionTitle(organizationID, canvasID uuid.UUID) string { } return org.Name + " - " + canvas.Name } + +// CanvasSessionInfo holds session + user info for the session browser. +type CanvasSessionInfo struct { + SessionID string + UserID string + UserName string + Status string + LastActiveAt *time.Time +} + +// ListCanvasSessions returns all agent sessions for a canvas with user info. +func (s *Service) ListCanvasSessions(organizationID, canvasID uuid.UUID) ([]CanvasSessionInfo, error) { + var results []struct { + SessionID string + UserID string + UserName string + Status string + LastActiveAt *time.Time + } + + err := database.Conn(). + Table("agent_sessions"). + Select("agent_sessions.id as session_id, agent_sessions.user_id, COALESCE(NULLIF(users.name, ''), 'Unknown') as user_name, agent_sessions.status, agent_sessions.last_active_at"). + Joins("LEFT JOIN users ON users.id = agent_sessions.user_id"). + Where("agent_sessions.organization_id = ? AND agent_sessions.canvas_id = ?", organizationID, canvasID). + Order("agent_sessions.updated_at DESC"). + Scan(&results).Error + if err != nil { + return nil, fmt.Errorf("list canvas sessions: %w", err) + } + + entries := make([]CanvasSessionInfo, len(results)) + for i, r := range results { + entries[i] = CanvasSessionInfo{ + SessionID: r.SessionID, + UserID: r.UserID, + UserName: r.UserName, + Status: r.Status, + LastActiveAt: r.LastActiveAt, + } + } + return entries, nil +} + +// GetSessionInOrg finds a session by ID within an organization, regardless of owner. +// Used for the session browser read-only view. +func (s *Service) GetSessionInOrg(organizationID, sessionID uuid.UUID) (*models.AgentSession, error) { + return models.FindAgentSessionInOrg(organizationID, sessionID) +} diff --git a/pkg/authorization/interceptor.go b/pkg/authorization/interceptor.go index 141f4de158..2f150a1c30 100644 --- a/pkg/authorization/interceptor.go +++ b/pkg/authorization/interceptor.go @@ -178,6 +178,12 @@ func NewAuthorizationInterceptor(authService Authorization) *AuthorizationInterc DomainType: models.DomainTypeOrganization, RequiredExperimentalFeatures: []string{features.FeatureClaudeManagedAgents}, }, + pbAgents.Agents_ListCanvasSessions_FullMethodName: { + Resource: "agents", + Action: "read", + DomainType: models.DomainTypeOrganization, + RequiredExperimentalFeatures: []string{features.FeatureClaudeManagedAgents}, + }, pbAgents.Agents_ListAgentChatMessages_FullMethodName: { Resource: "agents", Action: "read", diff --git a/pkg/grpc/actions/agents/common.go b/pkg/grpc/actions/agents/common.go index 10515d7a10..42b9478bd3 100644 --- a/pkg/grpc/actions/agents/common.go +++ b/pkg/grpc/actions/agents/common.go @@ -22,6 +22,8 @@ type AgentsService interface { SendMessage(ctx context.Context, organizationID, userID, sessionID uuid.UUID, content string, mode ...string) (*models.AgentSessionMessage, error) InterruptSession(ctx context.Context, organizationID, userID, sessionID uuid.UUID) error DefineOutcome(ctx context.Context, organizationID, userID, sessionID uuid.UUID, description, rubric string, maxIterations int) error + ListCanvasSessions(organizationID, canvasID uuid.UUID) ([]agentservice.CanvasSessionInfo, error) + GetSessionInOrg(organizationID, sessionID uuid.UUID) (*models.AgentSession, error) } func agentModeFromProto(mode pb.AgentMode) string { diff --git a/pkg/grpc/actions/agents/list_agent_chat_messages.go b/pkg/grpc/actions/agents/list_agent_chat_messages.go index 0623ca997b..db37bb0bec 100644 --- a/pkg/grpc/actions/agents/list_agent_chat_messages.go +++ b/pkg/grpc/actions/agents/list_agent_chat_messages.go @@ -24,12 +24,15 @@ func ListAgentChatMessages(_ context.Context, svc AgentsService, orgID, userID s return nil, status.Error(codes.InvalidArgument, "invalid chat id") } + // Try user-scoped first, then fall back to org-scoped (for session browser read-only view) if _, err := svc.GetSession(org, user, chatID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Error(codes.NotFound, "agent chat not found") + if _, orgErr := svc.GetSessionInOrg(org, chatID); orgErr != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "agent chat not found") + } + log.WithError(err).WithField("chat_id", chatID).Error("failed to load agent chat") + return nil, status.Error(codes.Internal, "failed to load agent chat") } - log.WithError(err).WithField("chat_id", chatID).Error("failed to load agent chat") - return nil, status.Error(codes.Internal, "failed to load agent chat") } var beforeID uuid.UUID diff --git a/pkg/grpc/actions/agents/list_canvas_sessions.go b/pkg/grpc/actions/agents/list_canvas_sessions.go new file mode 100644 index 0000000000..96e48e53ca --- /dev/null +++ b/pkg/grpc/actions/agents/list_canvas_sessions.go @@ -0,0 +1,51 @@ +package agents + +import ( + "context" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + pb "github.com/superplanehq/superplane/pkg/protos/agents" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func ListCanvasSessions(ctx context.Context, svc AgentsService, orgID, userID string, req *pb.ListCanvasSessionsRequest) (*pb.ListCanvasSessionsResponse, error) { + org, _, err := parseOrgUser(orgID, userID) + if err != nil { + return nil, err + } + + canvasID, err := uuid.Parse(req.CanvasId) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid canvas id") + } + + if err := ensureCanvas(org, canvasID); err != nil { + return nil, err + } + + entries, err := svc.ListCanvasSessions(org, canvasID) + if err != nil { + log.WithError(err).Error("failed to list canvas sessions") + return nil, status.Error(codes.Internal, "failed to list canvas sessions") + } + + sessions := make([]*pb.CanvasSessionInfo, len(entries)) + for i, e := range entries { + var lastActivity *timestamppb.Timestamp + if e.LastActiveAt != nil { + lastActivity = timestamppb.New(*e.LastActiveAt) + } + sessions[i] = &pb.CanvasSessionInfo{ + Id: e.SessionID, + UserId: e.UserID, + UserName: e.UserName, + Status: e.Status, + LastActivityAt: lastActivity, + } + } + + return &pb.ListCanvasSessionsResponse{Sessions: sessions}, nil +} diff --git a/pkg/grpc/agent_service.go b/pkg/grpc/agent_service.go index 58c8320223..a0984f4702 100644 --- a/pkg/grpc/agent_service.go +++ b/pkg/grpc/agent_service.go @@ -80,3 +80,11 @@ func (s *AgentsService) DefineAgentOutcome(ctx context.Context, req *pb.DefineAg } return agentsActions.DefineAgentOutcome(ctx, s.service, orgID, userID, req) } + +func (s *AgentsService) ListCanvasSessions(ctx context.Context, req *pb.ListCanvasSessionsRequest) (*pb.ListCanvasSessionsResponse, error) { + orgID, userID, err := s.requestContext(ctx) + if err != nil { + return nil, err + } + return agentsActions.ListCanvasSessions(ctx, s.service, orgID, userID, req) +} diff --git a/pkg/models/agent_session.go b/pkg/models/agent_session.go index 401cf322e4..e509d6dcce 100644 --- a/pkg/models/agent_session.go +++ b/pkg/models/agent_session.go @@ -166,3 +166,15 @@ func FailStuckStreamingSessions(cutoff time.Time) ([]AgentSession, error) { } return stuck, nil } + +// FindAgentSessionInOrg finds a session by org and session ID without filtering by user. +func FindAgentSessionInOrg(organizationID, sessionID uuid.UUID) (*AgentSession, error) { + var session AgentSession + err := database.Conn(). + Where("organization_id = ? AND id = ?", organizationID, sessionID). + First(&session).Error + if err != nil { + return nil, err + } + return &session, nil +} diff --git a/protos/agents.proto b/protos/agents.proto index 537ad0e3fa..49137bef2e 100644 --- a/protos/agents.proto +++ b/protos/agents.proto @@ -81,6 +81,17 @@ service Agents { tags: "Agent"; }; } + + rpc ListCanvasSessions(ListCanvasSessionsRequest) returns (ListCanvasSessionsResponse) { + option (google.api.http) = { + get: "/api/v1/agents/canvases/{canvas_id}/sessions" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List all agent sessions for a canvas"; + description: "Returns all user sessions for the given canvas, including user info and last activity."; + tags: "Agent"; + }; + } } enum AgentMode { @@ -135,6 +146,23 @@ message DefineAgentOutcomeRequest { message DefineAgentOutcomeResponse {} +message ListCanvasSessionsRequest { + string canvas_id = 1; +} + +message ListCanvasSessionsResponse { + repeated CanvasSessionInfo sessions = 1; +} + +message CanvasSessionInfo { + string id = 1; + string user_id = 2; + string user_name = 3; + string user_avatar_url = 4; + string status = 5; + google.protobuf.Timestamp last_activity_at = 6; +} + message AgentChatInfo { string id = 1; string canvas_id = 2; diff --git a/web_src/src/components/CanvasToolSidebar/AgentConversationTranscript.tsx b/web_src/src/components/CanvasToolSidebar/AgentConversationTranscript.tsx index 12696708c1..7d795ab89f 100644 --- a/web_src/src/components/CanvasToolSidebar/AgentConversationTranscript.tsx +++ b/web_src/src/components/CanvasToolSidebar/AgentConversationTranscript.tsx @@ -1,5 +1,6 @@ import { Bot, ChevronRight, Loader2, SquareTerminal } from "lucide-react"; -import { memo, useCallback, useEffect, useState, type RefObject } from "react"; +import { memo, useCallback, useContext, useEffect, useRef, useState, type RefObject } from "react"; +import { AccountContext } from "@/contexts/accountContextState"; import { formatSystemNotification, isSystemNotification } from "@/components/AgentSidebar/systemMessages"; import type { RubricCategory } from "@/components/AgentSidebar/widgets/parser"; import { RichMessage } from "@/components/AgentSidebar/widgets/RichMessage"; @@ -134,6 +135,7 @@ const MessageRow = memo(function MessageRow({ } const isUser = message.role === "user"; + const { account } = useContext(AccountContext); return (
Sessions on this canvas
+ {sessions.length === 0 ? ( +No sessions yet.
+ ) : ( +