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 && (