Skip to content
Merged
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
250 changes: 250 additions & 0 deletions simulator-ui/src/WorkbenchDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,35 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
const [chatHistoryLoading, setChatHistoryLoading] = useState(false);
const [chatHistoryError, setChatHistoryError] = useState<string | null>(null);
const [copiedStatePath, setCopiedStatePath] = useState(false);
const [copiedCodexLoginCommand, setCopiedCodexLoginCommand] = useState(false);
const [codexTrustPending, setCodexTrustPending] = useState(false);
const [codexTrustError, setCodexTrustError] = useState<string | null>(null);
const [codexTrustSuccess, setCodexTrustSuccess] = useState<string | null>(
null,
);
const [codexWorkspaceWriteEnabled, setCodexWorkspaceWriteEnabled] = useState<
boolean | null
>(null);
const [codexWorkspaceLoggedIn, setCodexWorkspaceLoggedIn] = useState<
boolean | null
>(null);
const [codexLoginStatusText, setCodexLoginStatusText] = useState<
string | null
>(null);
const [codexTrustedPath, setCodexTrustedPath] = useState<string | null>(null);
const [codexTrustOverlayDismissed, setCodexTrustOverlayDismissed] = useState(
false,
);
const initializedChipTrackingRef = useRef(false);
const seenRatingChipIdsRef = useRef(new Set<string>());
const seenFlagChipIdsRef = useRef(new Set<string>());
const showCodexTrustOverlay = (codexWorkspaceWriteEnabled === false ||
codexWorkspaceLoggedIn === false) &&
!codexTrustOverlayDismissed || Boolean(codexTrustError);
const workspaceIdForTrust = (sessionId ?? run.id) || undefined;
const codexLoginCommand = codexTrustedPath
? `CODEX_HOME="${codexTrustedPath}/.codex" codex login`
: 'CODEX_HOME="<workspace>/.codex" codex login';
const resolvedStatePath = useMemo(() => {
if (statePath) return statePath;
const meta = sessionDetail?.meta;
Expand Down Expand Up @@ -327,8 +353,67 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
initializedChipTrackingRef.current = false;
seenRatingChipIdsRef.current.clear();
seenFlagChipIdsRef.current.clear();
setCodexTrustPending(false);
setCodexTrustError(null);
setCodexTrustSuccess(null);
setCodexWorkspaceWriteEnabled(null);
setCodexWorkspaceLoggedIn(null);
setCodexLoginStatusText(null);
setCodexTrustedPath(null);
setCopiedCodexLoginCommand(false);
setCodexTrustOverlayDismissed(false);
}, [sessionId]);

useEffect(() => {
if (!open) return;
if (!workspaceIdForTrust) return;
let canceled = false;
setCodexTrustError(null);
fetch(
`/api/codex/trust-workspace?workspaceId=${
encodeURIComponent(workspaceIdForTrust)
}`,
)
.then(async (response) => {
const payload = await response.json() as {
ok?: boolean;
trusted?: boolean;
writeEnabled?: boolean;
codexLoggedIn?: boolean;
codexLoginStatus?: string;
trustedPath?: string;
error?: string;
};
if (!response.ok || payload.ok === false) {
throw new Error(payload.error || response.statusText);
}
if (canceled) return;
setCodexWorkspaceWriteEnabled(payload.writeEnabled === true);
setCodexWorkspaceLoggedIn(payload.codexLoggedIn === true);
setCodexLoginStatusText(
typeof payload.codexLoginStatus === "string"
? payload.codexLoginStatus
: null,
);
setCodexTrustedPath(
typeof payload.trustedPath === "string" ? payload.trustedPath : null,
);
})
.catch((err) => {
if (canceled) return;
setCodexWorkspaceWriteEnabled(null);
setCodexWorkspaceLoggedIn(null);
setCodexLoginStatusText(null);
setCodexTrustError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (canceled) return;
});
return () => {
canceled = true;
};
}, [open, workspaceIdForTrust]);

useEffect(() => {
if (loading) return;
const currentRatingChipIds = new Set(
Expand Down Expand Up @@ -482,6 +567,11 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
window.setTimeout(() => setCopiedStatePath(false), 1200);
};
}, [resolvedStatePath]);
const handleCopyCodexLoginCommand = useCallback(() => {
navigator.clipboard?.writeText(codexLoginCommand);
setCopiedCodexLoginCommand(true);
window.setTimeout(() => setCopiedCodexLoginCommand(false), 1200);
}, [codexLoginCommand]);
useEffect(() => {
if (!open) return;
if (!onClose) return;
Expand All @@ -493,6 +583,90 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, open]);
const trustWorkspaceInCodex = useCallback(async () => {
setCodexTrustPending(true);
setCodexTrustError(null);
setCodexTrustSuccess(null);
try {
const statusResponse = await fetch(
`/api/codex/trust-workspace?workspaceId=${
encodeURIComponent(workspaceIdForTrust ?? "")
}`,
);
const statusPayload = await statusResponse.json() as {
ok?: boolean;
trusted?: boolean;
writeEnabled?: boolean;
codexLoggedIn?: boolean;
codexLoginStatus?: string;
trustedPath?: string;
error?: string;
};
if (!statusResponse.ok || statusPayload.ok === false) {
throw new Error(statusPayload.error || statusResponse.statusText);
}
if (
statusPayload.writeEnabled === true &&
statusPayload.codexLoggedIn === true
) {
setCodexWorkspaceWriteEnabled(true);
setCodexWorkspaceLoggedIn(true);
setCodexTrustSuccess(
"Workspace is already configured for Codex writes.",
);
setCodexTrustOverlayDismissed(true);
return;
}
setCodexWorkspaceWriteEnabled(statusPayload.writeEnabled === true);
setCodexWorkspaceLoggedIn(statusPayload.codexLoggedIn === true);
setCodexLoginStatusText(
typeof statusPayload.codexLoginStatus === "string"
? statusPayload.codexLoginStatus
: null,
);
setCodexTrustedPath(
typeof statusPayload.trustedPath === "string"
? statusPayload.trustedPath
: null,
);

const response = await fetch("/api/codex/trust-workspace", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspaceId: workspaceIdForTrust }),
});
const payload = await response.json() as {
ok?: boolean;
error?: string;
trustedPath?: string;
writeEnabled?: boolean;
codexLoggedIn?: boolean;
codexLoginStatus?: string;
};
if (!response.ok || payload.ok === false) {
throw new Error(payload.error || response.statusText);
}
const trustedPath = typeof payload.trustedPath === "string"
? payload.trustedPath
: "workspace";
setCodexTrustSuccess(`Codex write enabled for: ${trustedPath}`);
setCodexWorkspaceWriteEnabled(payload.writeEnabled === true);
setCodexWorkspaceLoggedIn(payload.codexLoggedIn === true);
setCodexLoginStatusText(
typeof payload.codexLoginStatus === "string"
? payload.codexLoginStatus
: null,
);
setCodexTrustedPath(
typeof payload.trustedPath === "string" ? payload.trustedPath : null,
);
setCodexTrustOverlayDismissed(payload.codexLoggedIn === true);
} catch (err) {
setCodexTrustError(err instanceof Error ? err.message : String(err));
} finally {
setCodexTrustPending(false);
}
}, [workspaceIdForTrust]);
if (!open) return null;
return (
<aside className="workbench-drawer-docked" role="dialog">
Expand Down Expand Up @@ -605,6 +779,81 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
chatHistoryOpen ? " is-history" : ""
}`}
>
{showCodexTrustOverlay && (
<div className="workbench-chat-readonly-overlay">
<div className="workbench-chat-readonly-card">
<h3 className="workbench-chat-readonly-title">
Codex setup required
</h3>
{codexWorkspaceWriteEnabled === false && (
<p className="workbench-chat-readonly-copy">
Codex write access is disabled for this
workspace. Trust this workspace to enable file
edits.
</p>
)}
<div className="workbench-chat-readonly-actions">
{codexWorkspaceWriteEnabled === false && (
<Button
variant="primary"
onClick={() => trustWorkspaceInCodex()}
disabled={codexTrustPending}
>
{codexTrustPending
? "Trusting..."
: "Trust workspace"}
</Button>
)}
</div>
{codexWorkspaceLoggedIn === false && (
<>
<p className="workbench-chat-readonly-copy">
Codex login is required for this workspace.
</p>
<p className="workbench-chat-readonly-copy">
Run this in this workspace to authenticate
Codex, then restart Gambit.
</p>
<div className="workbench-chat-command-row">
<pre className="workbench-chat-command-code">
<code>{codexLoginCommand}</code>
</pre>
<Button
variant="secondary"
size="small"
onClick={handleCopyCodexLoginCommand}
>
<Icon
name={copiedCodexLoginCommand
? "copied"
: "copy"}
size={14}
/>
{copiedCodexLoginCommand
? "Copied"
: "Copy"}
</Button>
</div>
</>
)}
{codexLoginStatusText &&
!/^not logged in$/i.test(
codexLoginStatusText.trim(),
) && <Callout>{codexLoginStatusText}</Callout>}
{codexTrustError && (
<div className="error">{codexTrustError}</div>
)}
<Button
variant="secondary"
onClick={() =>
setCodexTrustOverlayDismissed(true)}
disabled={codexTrustPending}
>
Dismiss
</Button>
</div>
</div>
)}
<Chat
composerChips={composerChips}
onComposerChipsChange={onComposerChipsChange}
Expand All @@ -621,6 +870,7 @@ export default function WorkbenchDrawer(props: WorkbenchDrawerProps) {
defaultOpen: false,
content: (
<div className="workbench-ratings">
{codexTrustSuccess && <Callout>{codexTrustSuccess}</Callout>}
{showCopyStatePath && handleCopyStatePath && (
<>
<Button variant="secondary" onClick={handleCopyStatePath}>
Expand Down
61 changes: 61 additions & 0 deletions simulator-ui/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,6 +2269,67 @@ code:not(pre *) {
.workbench-chat-current.is-history {
transform: translateX(85%);
}
.workbench-chat-readonly-overlay {
position: absolute;
inset: 0;
z-index: 2;
background: rgba(248, 250, 252, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.workbench-chat-readonly-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: calc(16px * var(--corner-radius-scale, 1));
corner-shape: squircle;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
width: 100%;
max-width: 640px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.workbench-chat-readonly-title {
margin: 0;
font-size: 16px;
}
.workbench-chat-readonly-copy {
margin: 0;
font-size: 13px;
color: var(--color-text-muted);
}
.workbench-chat-readonly-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.workbench-chat-command-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.workbench-chat-command-code {
margin: 0;
flex: 1;
max-width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: calc(10px * var(--corner-radius-scale, 1));
corner-shape: squircle;
background: var(--color-surface-muted);
overflow-x: auto;
}
.workbench-chat-command-code code {
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.gds-accordion .gds-accordion-open-only {
display: none;
}
Expand Down
5 changes: 5 additions & 0 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,14 @@ function defaultCommandRunner(input: {
onStdoutLine?: (line: string) => void;
}): Promise<CommandOutput> {
const codexBin = Deno.env.get(CODEX_BIN_ENV)?.trim() || "codex";
const env = Deno.env.toObject();
if (!env.CODEX_HOME || env.CODEX_HOME.trim().length === 0) {
env.CODEX_HOME = path.join(input.cwd, ".codex");
}
const child = new Deno.Command(codexBin, {
args: input.args,
cwd: input.cwd,
env,
stdout: "piped",
stderr: "piped",
}).spawn();
Expand Down
Loading