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 (
@@ -159,7 +161,23 @@ const MessageRow = memo(function MessageRow({ )}
{message.createdAt ? ( - {formatTime(message.createdAt)} +
+ {isUser && account ? ( + <> + {formatTime(message.createdAt)} + {account.avatar_url ? ( + + ) : ( +
+ {(account.name?.[0] ?? "?").toUpperCase()} +
+ )} + {account.name?.split(" ")[0]} + + ) : ( + {formatTime(message.createdAt)} + )} +
) : null} ); @@ -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})` : ""}...` diff --git a/web_src/src/components/CanvasToolSidebar/AgentTabPanel.tsx b/web_src/src/components/CanvasToolSidebar/AgentTabPanel.tsx index 5c429614ad..ba3e5843d1 100644 --- a/web_src/src/components/CanvasToolSidebar/AgentTabPanel.tsx +++ b/web_src/src/components/CanvasToolSidebar/AgentTabPanel.tsx @@ -2,6 +2,8 @@ import { Loader2 } from "lucide-react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import type { AgentMode } from "@/components/AgentSidebar/agentMode"; import { AccountContext } from "@/contexts/accountContextState"; +import { SessionListView } from "./SessionListView"; +import { Users } from "lucide-react"; import { createSystemMessage } from "@/components/AgentSidebar/systemMessages"; import { ChatComposer } from "@/components/AgentSidebar/ChatComposer"; import { useChatScroll } from "@/components/AgentSidebar/useChatScroll"; @@ -12,6 +14,7 @@ import type { RubricCategory } from "@/components/AgentSidebar/widgets/parser"; import { useAgentChatMessages, useCanvasAgentChat, + useCanvasSessions, useDefineAgentOutcome, useInterruptAgentChat, useSendAgentChatMessage, @@ -39,6 +42,7 @@ type ChatConversationProps = { agentMode: AgentMode; onModeSwitch: (mode: AgentMode) => void; isEditing: boolean; + readOnly?: boolean; }; type DraftActionsBarProps = { @@ -67,6 +71,9 @@ export function AgentTabPanel({ toolSidebarState }: { toolSidebarState: CanvasTo const chatId = chatQuery.data?.id ?? null; const { account } = useContext(AccountContext); const firstName = account?.name?.split(" ")[0] ?? "there"; + const [viewMode, setViewMode] = useState<"my-session" | "session-list" | "viewing-session">("my-session"); + const [viewingSessionId, setViewingSessionId] = useState(null); + const sessionsQuery = useCanvasSessions(canvasId, organizationId, viewMode === "session-list"); if (chatQuery.isLoading || !chatId) { return ( @@ -85,15 +92,62 @@ export function AgentTabPanel({ toolSidebarState }: { toolSidebarState: CanvasTo ); } + if (viewMode === "session-list") { + return ( + { + setViewingSessionId(sessionId); + setViewMode("viewing-session"); + }} + onBack={() => setViewMode("my-session")} + /> + ); + } + + if (viewMode === "viewing-session" && viewingSessionId) { + return ( +
+
+ +
+ +
+ ); + } + return ( - +
+
+ +
+ +
); } @@ -104,6 +158,7 @@ function ChatConversation({ agentMode, onModeSwitch, isEditing, + readOnly, }: ChatConversationProps) { const messagesQuery = useAgentChatMessages(chatId, organizationId, true); const sendMutation = useSendAgentChatMessage(organizationId, canvasId); @@ -116,8 +171,9 @@ function ChatConversation({ const { account } = useContext(AccountContext); const greetingFirstName = account?.name?.split(" ")[0] ?? "there"; - // Prepend a synthetic greeting as the first message so it never disappears + // Prepend a synthetic greeting as the first message (only for own session) const messages = useMemo(() => { + if (readOnly) return rawMessages; const greeting: AgentMessage = { id: "__greeting__", role: "assistant", @@ -128,13 +184,14 @@ function ChatConversation({ toolStatus: "", }; return [greeting, ...rawMessages]; - }, [rawMessages, greetingFirstName]); + }, [rawMessages, greetingFirstName, readOnly]); const showThinking = useThinkingIndicator(rawMessages, status); // Auto-kickoff: send a boot message when session is new (no messages yet) const bootState = useRef<"idle" | "sending" | "sent">("idle"); useEffect(() => { + if (readOnly) return; if (bootState.current !== "idle") return; if (!messagesQuery.data || messagesQuery.isLoading) return; @@ -209,6 +266,7 @@ function ChatConversation({ onVersionPublished={() => setOutcomeState(null)} /> + {!readOnly && ( + )} ); } diff --git a/web_src/src/components/CanvasToolSidebar/SessionListView.tsx b/web_src/src/components/CanvasToolSidebar/SessionListView.tsx new file mode 100644 index 0000000000..076e4cf137 --- /dev/null +++ b/web_src/src/components/CanvasToolSidebar/SessionListView.tsx @@ -0,0 +1,77 @@ +import { ArrowLeft, Circle } from "lucide-react"; +import type { CanvasSession } from "@/hooks/useAgentChats"; + +interface SessionListViewProps { + sessions: CanvasSession[]; + currentUserId: string; + onSelectSession: (sessionId: string) => void; + onBack: () => void; +} + +export function SessionListView({ sessions, currentUserId, onSelectSession, onBack }: SessionListViewProps) { + return ( +
+
+ +
+
+
+

Sessions on this canvas

+ {sessions.length === 0 ? ( +

No sessions yet.

+ ) : ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function formatTimeAgo(timestamp: string | null): string { + if (!timestamp) return "No activity"; + const diff = Date.now() - new Date(timestamp).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/web_src/src/hooks/useAgentChats.ts b/web_src/src/hooks/useAgentChats.ts index 2d23b05723..58bb68483a 100644 --- a/web_src/src/hooks/useAgentChats.ts +++ b/web_src/src/hooks/useAgentChats.ts @@ -148,3 +148,35 @@ export function useDefineAgentOutcome(organizationId: string | undefined) { }, }); } + +export type CanvasSession = { + id: string; + userId: string; + userName: string; + userAvatarUrl: string; + status: string; + lastActivityAt: string | null; +}; + +export function useCanvasSessions(canvasId: string | undefined, organizationId: string | undefined, enabled: boolean) { + return useQuery({ + queryKey: [...agentChatKeys.all, "sessions", canvasId], + enabled: enabled && Boolean(canvasId), + queryFn: async (): Promise => { + const res = await fetch(`/api/v1/agents/canvases/${canvasId}/sessions`, { + headers: { "x-organization-id": organizationId ?? "" }, + credentials: "include", + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.sessions ?? []).map((s: Record) => ({ + id: s.id ?? "", + userId: s.userId ?? "", + userName: s.userName ?? "", + userAvatarUrl: s.userAvatarUrl ?? "", + status: s.status ?? "idle", + lastActivityAt: s.lastActivityAt ?? null, + })); + }, + }); +}