diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 05410c9de..dd00aa769 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -36,6 +36,8 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato import { Tooltip } from "@/components/workspace/tooltip"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; import { useAgent } from "@/core/agents"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useBrowserControlEnabled } from "@/core/features"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -63,6 +65,8 @@ import { cn } from "@/lib/utils"; export default function AgentChatPage() { const { t } = useI18n(); + const { user } = useAuth(); + const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL); const router = useRouter(); const { agent_name } = useParams<{ @@ -457,6 +461,7 @@ export default function AgentChatPage() { onGoalChange={setLocalGoal} onSubmit={handleSubmit} onStop={handleStop} + canStopStreaming={canStopStreaming} /> {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index 2b2a16a86..fd6b1895d 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -36,6 +36,8 @@ import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; import { Welcome } from "@/components/workspace/welcome"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useBrowserControlEnabled } from "@/core/features"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -71,6 +73,8 @@ import { useThreadChat } from "./use-thread-chat"; export default function ChatPage() { const { t } = useI18n(); + const { user } = useAuth(); + const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL); const router = useRouter(); const searchParams = useSearchParams(); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -549,6 +553,7 @@ export default function ChatPage() { onPrepareThread={ensureProjectThread} onSubmit={handleSubmit} onStop={handleStop} + canStopStreaming={canStopStreaming} /> ) : (
, "onSubmit"> & { assistantId?: string | null; @@ -356,6 +357,13 @@ export function InputBox({ options?: InputBoxSubmitOptions, ) => void | Promise; onStop?: () => void; + /** + * Whether the caller's role holds `runs:cancel` (RFC #4063 Phase 4). + * Defaults to true so callers that don't resolve permissions (pre-Phase-4 + * backends, storybook) keep today's behavior; the Gateway route guard + * stays the enforcement point. + */ + canStopStreaming?: boolean; }) { const { locale, t } = useI18n(); const queryClient = useQueryClient(); @@ -1163,6 +1171,14 @@ export function InputBox({ ); const handleStopStreaming = useCallback(() => { + // Roles denied runs:cancel must not interrupt the in-progress turn — + // the Gateway would 403 the cancel anyway. The submit-button click is + // the only live entry point today (handleSubmit returns early with the + // pleaseWaitStreaming toast while streaming), but gate in the handler + // as defense-in-depth so any future stop path is covered too. + if (!canStopStreaming) { + return; + } // Mark the in-progress turn as user-interrupted so the next // streaming->ready transition does not suggest follow-ups for it. stoppedByUserRef.current = true; @@ -1170,7 +1186,7 @@ export function InputBox({ setFollowupsHidden(true); setFollowupsLoading(false); onStop?.(); - }, [onStop]); + }, [canStopStreaming, onStop]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -1367,6 +1383,9 @@ export function InputBox({ const isComposerDisabled = disabled === true; const isMockThread = isMock === true; const composerLocked = isComposerDisabled || polishingInput; + // A denied runs:cancel role sees a disabled stop affordance, not a removed + // one — the composer must still show that a turn is in flight. + const stopDenied = status === "streaming" && !canStopStreaming; const inputPolishUndoAvailable = !polishingInput && inputPolishUndo !== null && @@ -2739,9 +2758,21 @@ export function InputBox({ { if (status === "streaming") { e.preventDefault(); diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 15af7cf3e..8bec3278b 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -47,6 +47,8 @@ import { } from "@/components/ui/sidebar"; import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat"; import { getAPIClient } from "@/core/api"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { useProjects } from "@/core/projects"; @@ -98,6 +100,8 @@ export function ThreadSidebarItem({ recentThreadId?: string | undefined; }) { const { t } = useI18n(); + const { user } = useAuth(); + const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE); const router = useRouter(); const pathname = usePathname(); const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } = @@ -372,11 +376,15 @@ export function ThreadSidebarItem({ onNewProject={() => setNewProjectDialogOpen(true)} onMoveProject={handleMoveProject} /> - - - - {t.common.delete} - + {canDeleteThreads && ( + <> + + + + {t.common.delete} + + + )} )} diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx index af271fd15..4dcb85068 100644 --- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -48,6 +48,8 @@ import { DropdownMenuGroup, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useI18n } from "@/core/i18n/hooks"; import { buildHumanInputResponseText, @@ -146,6 +148,8 @@ function promptMessageFiles(message: PromptInputMessage) { export function SidecarPanel({ className }: { className?: string }) { const { t } = useI18n(); + const { user } = useAuth(); + const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE); const sidecar = useSidecar(); const { thread: parentThread } = useParentThread(); const [localSettings] = useLocalSettings(); @@ -539,7 +543,7 @@ export function SidecarPanel({ className }: { className?: string }) { : t.sidecar.noContext}
- {hasSidecarThread && ( + {hasSidecarThread && canDeleteThreads && (