Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions pkg/agents/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
6 changes: 6 additions & 0 deletions pkg/authorization/interceptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions pkg/grpc/actions/agents/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions pkg/grpc/actions/agents/list_agent_chat_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions pkg/grpc/actions/agents/list_canvas_sessions.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions pkg/grpc/agent_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 12 additions & 0 deletions pkg/models/agent_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 28 additions & 0 deletions protos/agents.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -134,6 +135,7 @@ const MessageRow = memo(function MessageRow({
}

const isUser = message.role === "user";
const { account } = useContext(AccountContext);

return (
<div className={cn("flex w-full min-w-0 flex-col", isUser ? "items-end" : "items-start")}>
Expand All @@ -159,7 +161,23 @@ const MessageRow = memo(function MessageRow({
)}
</div>
{message.createdAt ? (
<span className="mt-0.5 px-1 text-[10px] text-slate-400">{formatTime(message.createdAt)}</span>
<div className="mt-0.5 flex items-center gap-1.5 px-1">
{isUser && account ? (
<>
<span className="text-[10px] text-slate-400">{formatTime(message.createdAt)}</span>
{account.avatar_url ? (
<img src={account.avatar_url} alt="" className="size-3.5 rounded-full" />
) : (
<div className="flex size-3.5 items-center justify-center rounded-full bg-slate-300 text-[8px] font-medium text-white">
{(account.name?.[0] ?? "?").toUpperCase()}
</div>
)}
<span className="text-[10px] text-slate-400">{account.name?.split(" ")[0]}</span>
</>
) : (
<span className="text-[10px] text-slate-400">{formatTime(message.createdAt)}</span>
)}
</div>
) : null}
</div>
);
Expand Down Expand Up @@ -226,8 +244,17 @@ function truncateQuestion(question: string): string {
}

function ToolGroupRow({ messages }: { messages: AgentMessage[] }) {
const [expanded, setExpanded] = useState(true);
const hasRunning = messages.some((message) => message.toolStatus === "started");
const [expanded, setExpanded] = useState(hasRunning);
const prevRunning = useRef(hasRunning);

useEffect(() => {
if (prevRunning.current && !hasRunning) {
setExpanded(false);
}
prevRunning.current = hasRunning;
}, [hasRunning]);

const count = messages.length;
const label = hasRunning
? `Running command${count > 1 ? ` (${count})` : ""}...`
Expand Down
Loading
Loading