Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions apps/codex-claw/src/routes/api/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const Route = createFileRoute('/api/send')({
const message = String(body.message ?? '')
const thinking =
typeof body.thinking === 'string' ? body.thinking : undefined
const runProfile =
typeof body.runProfile === 'string' ? body.runProfile : undefined
const confirmedRisk = body.confirmedRisk === true

const parsedAttachments = parseAttachments(body.attachments)
if (!parsedAttachments.ok) {
Expand Down Expand Up @@ -179,6 +182,8 @@ export const Route = createFileRoute('/api/send')({
thinking,
attachments,
contextBlock,
runProfile,
confirmedRisk,
idempotencyKey:
typeof body.idempotencyKey === 'string'
? body.idempotencyKey
Expand Down
6 changes: 6 additions & 0 deletions apps/codex-claw/src/routes/api/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ function workspaceInput(body: Record<string, unknown>) {
typeof body.codexSandbox === 'string'
? body.codexSandbox.trim()
: undefined,
codexApproval:
typeof body.codexApproval === 'string'
? body.codexApproval.trim()
: undefined,
runProfile:
typeof body.runProfile === 'string' ? body.runProfile.trim() : undefined,
codexWorkdir:
typeof body.codexWorkdir === 'string'
? body.codexWorkdir.trim()
Expand Down
12 changes: 11 additions & 1 deletion apps/codex-claw/src/screens/chat/chat-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { shouldRedirectToConnect } from './hooks/use-chat-error-state'
import { useChatRedirect } from './hooks/use-chat-redirect'
import type { AttachmentFile } from '@/components/attachment-button'
import type { ChatComposerHelpers } from './components/chat-composer'
import type { RepoContextSelection } from './types'
import type { RepoContextSelection, RunProfileId } from './types'
import { useExport } from '@/hooks/use-export'
import { useChatSettings } from '@/hooks/use-chat-settings'
import { cn, randomUUID } from '@/lib/utils'
Expand Down Expand Up @@ -234,6 +234,8 @@ export function ChatScreen({
skipOptimistic = false,
attachments?: Array<AttachmentFile>,
contextSelections?: Array<RepoContextSelection>,
runProfile?: RunProfileId,
confirmedRisk?: boolean,
) {
let optimisticClientId = ''
if (!skipOptimistic) {
Expand Down Expand Up @@ -279,6 +281,8 @@ export function ChatScreen({
idempotencyKey: randomUUID(),
attachments: attachmentsPayload,
contextSelections,
runProfile,
confirmedRisk,
}),
})
.then(async (res) => {
Expand Down Expand Up @@ -357,6 +361,8 @@ export function ChatScreen({
(body: string, helpers: ChatComposerHelpers) => {
const attachments = helpers.attachments
const contextSelections = helpers.contextSelections
const runProfile = helpers.runProfile
const confirmedRisk = helpers.confirmedRisk
if (
body.length === 0 &&
(!attachments || attachments.length === 0) &&
Expand Down Expand Up @@ -384,6 +390,8 @@ export function ChatScreen({
optimisticMessage,
attachments,
contextSelections,
runProfile,
confirmedRisk,
})
if (onSessionResolved) {
onSessionResolved({ sessionKey, friendlyId })
Expand Down Expand Up @@ -424,6 +432,8 @@ export function ChatScreen({
false,
attachments,
contextSelections,
runProfile,
confirmedRisk,
)
},
[
Expand Down
115 changes: 112 additions & 3 deletions apps/codex-claw/src/screens/chat/components/chat-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { memo, useCallback, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowUp02Icon } from '@hugeicons/core-free-icons'
import { fetchWorkspaces, updateWorkspace } from '../chat-queries'
import {
RepoContextButton,
RepoContextPanel,
RepoContextSummary,
} from './repo-context-picker'
import type { Ref } from 'react'
import type { RepoContextSelection } from '../types'
import type { RepoContextSelection, RunProfileId } from '../types'

import type { AttachmentFile } from '@/components/attachment-button'
import {
Expand All @@ -34,8 +35,34 @@ type ChatComposerHelpers = {
setValue: (value: string) => void
attachments?: Array<AttachmentFile>
contextSelections?: Array<RepoContextSelection>
runProfile?: RunProfileId
confirmedRisk?: boolean
}

const runProfiles = [
{
id: 'read-only-inspect',
label: 'Inspect',
sandbox: 'read-only',
approval: 'untrusted',
requiresConfirmation: false,
},
{
id: 'workspace-write',
label: 'Write',
sandbox: 'workspace-write',
approval: 'on-request',
requiresConfirmation: true,
},
{
id: 'elevated-manual-review',
label: 'Elevated',
sandbox: 'danger-full-access',
approval: 'untrusted',
requiresConfirmation: true,
},
] as const

function ChatComposerComponent({
onSubmit,
isLoading,
Expand All @@ -47,6 +74,10 @@ function ChatComposerComponent({
const [contextSelections, setContextSelections] = useState<
Array<RepoContextSelection>
>([])
const [runProfile, setRunProfile] =
useState<RunProfileId>('read-only-inspect')
const [confirmedRisk, setConfirmedRisk] = useState(false)
const [activeWorkspaceId, setActiveWorkspaceId] = useState('')
const promptRef = useRef<HTMLTextAreaElement | null>(null)
const valueRef = useRef('')
const setValueRef = useRef<((value: string) => void) | null>(null)
Expand All @@ -70,8 +101,29 @@ function ChatComposerComponent({
})
setContextSelections([])
setContextOpen(false)
setConfirmedRisk(false)
focusPrompt()
}, [focusPrompt])
useEffect(() => {
let cancelled = false
fetchWorkspaces()
.then((data) => {
if (cancelled) return
const activeWorkspace = data.workspaces.find(
(workspace) => workspace.id === data.activeWorkspaceId,
)
setActiveWorkspaceId(data.activeWorkspaceId)
if (activeWorkspace?.runProfile) {
setRunProfile(activeWorkspace.runProfile)
}
})
.catch(() => {
// ignore
})
return () => {
cancelled = true
}
}, [])
const handleFileSelect = useCallback((file: AttachmentFile) => {
setAttachments((prev) => [...prev, file])
}, [])
Expand All @@ -89,6 +141,28 @@ function ChatComposerComponent({
prev.filter((selection) => selection.path !== path),
)
}, [])
const activeRunProfile = useMemo(() => {
return (
runProfiles.find((profile) => profile.id === runProfile) ?? runProfiles[0]
)
}, [runProfile])
const handleRunProfileChange = useCallback(
(value: string) => {
const nextProfile =
runProfiles.find((profile) => profile.id === value) ?? runProfiles[0]
setRunProfile(nextProfile.id)
setConfirmedRisk(false)
if (activeWorkspaceId) {
void updateWorkspace({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: updateWorkspace result discarded with void — local state updates before it, so a failure silently leaves UI and server out of sync. Add .catch() that reverts the optimistic state or surfaces an error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/codex-claw/src/screens/chat/components/chat-composer.tsx, line 156:

<comment>`updateWorkspace` result discarded with `void` — local state updates before it, so a failure silently leaves UI and server out of sync. Add `.catch()` that reverts the optimistic state or surfaces an error.</comment>

<file context>
@@ -89,6 +141,28 @@ function ChatComposerComponent({
+      setRunProfile(nextProfile.id)
+      setConfirmedRisk(false)
+      if (activeWorkspaceId) {
+        void updateWorkspace({
+          id: activeWorkspaceId,
+          runProfile: nextProfile.id,
</file context>

id: activeWorkspaceId,
runProfile: nextProfile.id,
codexSandbox: nextProfile.sandbox,
codexApproval: nextProfile.approval,
})
}
Comment on lines +155 to +162
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Run profile changes can be saved to a stale workspace id. activeWorkspaceId is captured at mount and reused later, so after switching workspaces this PATCH may update the wrong workspace.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/codex-claw/src/screens/chat/components/chat-composer.tsx, line 155:

<comment>Run profile changes can be saved to a stale workspace id. `activeWorkspaceId` is captured at mount and reused later, so after switching workspaces this PATCH may update the wrong workspace.</comment>

<file context>
@@ -89,6 +141,28 @@ function ChatComposerComponent({
+        runProfiles.find((profile) => profile.id === value) ?? runProfiles[0]
+      setRunProfile(nextProfile.id)
+      setConfirmedRisk(false)
+      if (activeWorkspaceId) {
+        void updateWorkspace({
+          id: activeWorkspaceId,
</file context>
Suggested change
if (activeWorkspaceId) {
void updateWorkspace({
id: activeWorkspaceId,
runProfile: nextProfile.id,
codexSandbox: nextProfile.sandbox,
codexApproval: nextProfile.approval,
})
}
void fetchWorkspaces()
.then((data) => {
if (!data.activeWorkspaceId) return
return updateWorkspace({
id: data.activeWorkspaceId,
runProfile: nextProfile.id,
codexSandbox: nextProfile.sandbox,
codexApproval: nextProfile.approval,
})
})
.catch(() => {
// ignore
})

},
[activeWorkspaceId],
)
const setComposerValue = useCallback(
(nextValue: string) => {
if (setValueRef.current) {
Expand All @@ -109,11 +183,14 @@ function ChatComposerComponent({
contextSelections.length === 0
)
return
if (activeRunProfile.requiresConfirmation && !confirmedRisk) return
onSubmit(body, {
reset,
setValue: setComposerValue,
attachments: validAttachments,
contextSelections,
runProfile,
confirmedRisk,
})
focusPrompt()
}, [
Expand All @@ -124,8 +201,12 @@ function ChatComposerComponent({
setComposerValue,
attachments,
contextSelections,
runProfile,
confirmedRisk,
activeRunProfile.requiresConfirmation,
])
const submitDisabled = disabled
const submitDisabled =
disabled || (activeRunProfile.requiresConfirmation && !confirmedRisk)

return (
<div
Expand Down Expand Up @@ -157,6 +238,34 @@ function ChatComposerComponent({
placeholder="Type a message…"
inputRef={promptRef}
/>
<div className="flex flex-wrap items-center gap-2 px-3 pb-2 text-xs text-primary-500">
<select
value={runProfile}
onChange={(event) => handleRunProfileChange(event.target.value)}
className="h-7 rounded-md border border-primary-200 bg-surface px-2 text-xs text-primary-800 outline-none"
>
{runProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.label}
</option>
))}
</select>
<span>
sandbox: {activeRunProfile.sandbox} · approval:{' '}
{activeRunProfile.approval}
</span>
{activeRunProfile.requiresConfirmation ? (
<label className="inline-flex items-center gap-1 text-amber-700">
<input
type="checkbox"
checked={confirmedRisk}
onChange={(event) => setConfirmedRisk(event.target.checked)}
className="size-3.5"
/>
confirm
</label>
) : null}
</div>
<PromptInputActions className="justify-end px-3">
<div className="flex items-center gap-2 min-h-8 flex-nowrap">
<PromptInputAction
Expand Down
65 changes: 65 additions & 0 deletions apps/codex-claw/src/screens/chat/components/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type WorkspaceDraft = Partial<
| 'name'
| 'codexCommand'
| 'codexSandbox'
| 'codexApproval'
| 'runProfile'
| 'codexWorkdir'
| 'stateDir'
>
Expand Down Expand Up @@ -97,6 +99,27 @@ function WorkspaceField({ label, children }: WorkspaceFieldProps) {

const newWorkspaceId = '__new__'
const sandboxOptions = ['read-only', 'workspace-write', 'danger-full-access']
const approvalOptions = ['untrusted', 'on-request', 'never']
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Approval dropdown is missing the supported on-failure policy, causing UI/backend mismatch for workspace approval settings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/codex-claw/src/screens/chat/components/settings-dialog.tsx, line 102:

<comment>Approval dropdown is missing the supported `on-failure` policy, causing UI/backend mismatch for workspace approval settings.</comment>

<file context>
@@ -97,6 +99,27 @@ function WorkspaceField({ label, children }: WorkspaceFieldProps) {
 
 const newWorkspaceId = '__new__'
 const sandboxOptions = ['read-only', 'workspace-write', 'danger-full-access']
+const approvalOptions = ['untrusted', 'on-request', 'never']
+const runProfileOptions = [
+  {
</file context>
Suggested change
const approvalOptions = ['untrusted', 'on-request', 'never']
const approvalOptions = ['untrusted', 'on-request', 'on-failure', 'never']

const runProfileOptions = [
{
id: 'read-only-inspect',
label: 'Read-only inspect',
sandbox: 'read-only',
approval: 'untrusted',
},
{
id: 'workspace-write',
label: 'Workspace write',
sandbox: 'workspace-write',
approval: 'on-request',
},
{
id: 'elevated-manual-review',
label: 'Elevated manual review',
sandbox: 'danger-full-access',
approval: 'untrusted',
},
] as const

function emptyWorkspaceDraft(
workspace?: WorkspaceSummary | null,
Expand All @@ -105,6 +128,8 @@ function emptyWorkspaceDraft(
name: workspace ? `${workspace.name} copy` : 'New workspace',
codexCommand: workspace?.codexCommand ?? 'codex',
codexSandbox: workspace?.codexSandbox ?? 'read-only',
codexApproval: workspace?.codexApproval ?? 'untrusted',
runProfile: workspace?.runProfile ?? 'read-only-inspect',
codexWorkdir: workspace?.codexWorkdir ?? '',
stateDir: workspace?.stateDir ?? '',
}
Expand Down Expand Up @@ -210,6 +235,8 @@ export function SettingsDialog({
name: selectedWorkspace.name,
codexCommand: selectedWorkspace.codexCommand,
codexSandbox: selectedWorkspace.codexSandbox,
codexApproval: selectedWorkspace.codexApproval,
runProfile: selectedWorkspace.runProfile,
codexWorkdir: selectedWorkspace.codexWorkdir,
stateDir: selectedWorkspace.stateDir,
})
Expand All @@ -219,6 +246,16 @@ export function SettingsDialog({
setDraft((current) => ({ ...current, [field]: value }))
}

function updateRunProfile(value: string) {
const profile = runProfileOptions.find((option) => option.id === value)
setDraft((current) => ({
...current,
runProfile: value as WorkspaceDraft['runProfile'],
codexSandbox: profile?.sandbox ?? current.codexSandbox,
codexApproval: profile?.approval ?? current.codexApproval,
}))
}

function handleNewWorkspace() {
setSelectedWorkspaceId(newWorkspaceId)
setDraft(emptyWorkspaceDraft(activeWorkspace ?? null))
Expand Down Expand Up @@ -376,6 +413,34 @@ export function SettingsDialog({
))}
</select>
</WorkspaceField>
<WorkspaceField label="Run profile">
<select
value={draft.runProfile ?? 'read-only-inspect'}
onChange={(event) => updateRunProfile(event.target.value)}
className="h-8.5 w-full rounded-lg border border-primary-200 bg-surface px-3 text-sm text-primary-900 outline-none focus-visible:ring-2 focus-visible:ring-primary-950"
>
{runProfileOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</WorkspaceField>
<WorkspaceField label="Approval">
<select
value={draft.codexApproval ?? 'untrusted'}
onChange={(event) =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changing sandbox or approval individually leaves runProfile stale — the saved profile won't match the actual sandbox/approval values if they were overridden after profile selection.

Either reset runProfile to a sentinel (e.g. 'custom' or undefined) when sandbox/approval are changed independently, or disable the individual sandbox/approval selects when a named profile is selected so the two don't drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/codex-claw/src/screens/chat/components/settings-dialog.tsx, line 432:

<comment>Changing sandbox or approval individually leaves `runProfile` stale — the saved profile won't match the actual sandbox/approval values if they were overridden after profile selection.

Either reset `runProfile` to a sentinel (e.g. `'custom'` or `undefined`) when sandbox/approval are changed independently, or disable the individual sandbox/approval selects when a named profile is selected so the two don't drift.</comment>

<file context>
@@ -376,6 +413,34 @@ export function SettingsDialog({
+              <WorkspaceField label="Approval">
+                <select
+                  value={draft.codexApproval ?? 'untrusted'}
+                  onChange={(event) =>
+                    updateDraft('codexApproval', event.target.value)
+                  }
</file context>

updateDraft('codexApproval', event.target.value)
}
className="h-8.5 w-full rounded-lg border border-primary-200 bg-surface px-3 text-sm text-primary-900 outline-none focus-visible:ring-2 focus-visible:ring-primary-950"
>
{approvalOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</WorkspaceField>
</div>

<div className="flex flex-wrap items-center justify-between gap-2">
Expand Down
Loading
Loading