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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.332"
VERSION = "0.261.334"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
12 changes: 12 additions & 0 deletions application/v2_ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
import { PublicWorkspacePage } from './pages/PublicWorkspacePage';
import { PublicDirectoryPage } from './pages/PublicDirectoryPage';
import { clearWorkspaceEditorDrafts } from './lib/workspaceEditorDrafts';
import { clearAgentAssistHandoffs, pruneAgentAssistHandoffs } from './lib/editorAssistHandoff';

Check warning on line 33 in application/v2_ui/src/App.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
import { useEditorAssistStore } from './stores/editorAssistStore';
import { useAssistThreadStore } from './stores/assistThreadStore';
import { ContentReviewPage } from './pages/ContentReviewPage';
import { TermsOfUsePage } from './pages/TermsOfUsePage';
import { AccessRestrictedPage } from './pages/AccessRestrictedPage';
Expand Down Expand Up @@ -124,8 +127,17 @@

useEffect(() => {
clearWorkspaceEditorDrafts();
clearAgentAssistHandoffs();

Check warning on line 130 in application/v2_ui/src/App.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
for (const record of Object.values(useEditorAssistStore.getState().turns)) {
useAssistThreadStore.getState().forgetThread(record.threadKey);
}
useEditorAssistStore.getState().reset();
}, [data?.user?.id, authExpired]);

useEffect(() => {
pruneAgentAssistHandoffs(authExpired ? '' : data?.user?.id ?? '', location.pathname, location.search);

Check warning on line 138 in application/v2_ui/src/App.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 138 in application/v2_ui/src/App.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
}, [data?.user?.id, authExpired, location.pathname, location.search]);

Check warning on line 139 in application/v2_ui/src/App.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.

useEffect(() => {
initializeTheme();
if (standalonePage) {
Expand Down
8 changes: 7 additions & 1 deletion application/v2_ui/src/components/chat/AssistThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface AssistThreadProps {
className?: string;
logClassName?: string;
composerClassName?: string;
/** Optional in-memory reading position for an editor returning from a setup detour. */
logPosition?: { scrollTop: number };
}

/** Seconds since a request started. Hidden from assistive technology so it is not read out every second. */
Expand Down Expand Up @@ -149,11 +151,13 @@ export function AssistThread({
className,
logClassName,
composerClassName,
logPosition,
}: AssistThreadProps) {
const id = useId();
const counterId = `${id}-counter`;
const limitId = `${id}-limit`;
const logRef = useRef<HTMLDivElement>(null);
const firstLogRender = useRef(true);
const { exchanges, pending } = thread;
const inputDisabled = disabled || !thread.key;
const canSubmit = !inputDisabled && !busy && !pending && !thread.overLimit
Expand All @@ -170,7 +174,8 @@ export function AssistThread({
useEffect(() => {
const log = logRef.current;
if (log) {
log.scrollTop = log.scrollHeight;
log.scrollTop = firstLogRender.current && logPosition ? logPosition.scrollTop : log.scrollHeight;
firstLogRender.current = false;
}
}, [turns.length, exchanges.length, pending?.id]);

Expand Down Expand Up @@ -261,6 +266,7 @@ export function AssistThread({
<div className={clsx('flex min-h-0 flex-1 flex-col', className)}>
<div
ref={logRef}
onScroll={logPosition ? (event) => { logPosition.scrollTop = event.currentTarget.scrollTop; } : undefined}
role="log"
aria-live="polite"
aria-label={logLabel}
Expand Down
68 changes: 63 additions & 5 deletions application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@

import { useEffect, useRef } from 'react';
import { clsx } from 'clsx';
import { AlertTriangle, Loader2, Sparkles, X } from 'lucide-react';
import { AlertTriangle, ArrowRight, CheckCircle2, Loader2, Sparkles, X } from 'lucide-react';
import { AssistThread } from '../chat/AssistThread';
import { GlassButton } from '../ui/primitives';
import { WorkflowAssistElapsed } from '../workflows/WorkflowAskAiTab';
import type { AssistExchange } from '../../lib/assistThread';
import type { EditorAssistKind } from '../../lib/editorAssist';
import type { EditorAssistTurnRecord } from '../../stores/editorAssistStore';
import { editorAssistTurnState, type EditorAssist } from './useEditorAssist';
import type { AgentAssistSession } from '../../lib/editorAssistHandoff';

Check warning on line 18 in application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

interface ActionSetup {
pending: readonly { reference: string; label: string; type: string; issues: readonly string[] }[];
selected: readonly string[];
onFinish: (reference: string) => void;
disabled: boolean;

Check warning on line 24 in application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
restoring: boolean;
}

interface QuickAction {
readonly label: string;
Expand Down Expand Up @@ -69,10 +78,11 @@
}

/** One answer's card: the reply, then what it did to the draft. */
function EditorAssistTurnCard({ turnId, record, assist }: {
function EditorAssistTurnCard({ turnId, record, assist, actionSetup }: {
turnId: string;
record: EditorAssistTurnRecord;
assist: EditorAssist;
actionSetup?: ActionSetup;
}) {
const state = editorAssistTurnState(record);
const undoRef = useRef<HTMLDivElement>(null);
Expand All @@ -97,6 +107,36 @@
return (
<div data-editor-assist-card={turnId} data-outcome={record.outcome} data-state={state} className="space-y-2">
{record.reply ? <p className="whitespace-pre-wrap break-words" data-editor-assist-reply>{record.reply}</p> : null}
{actionSetup && record.draftedActions?.map((item) => {
const pending = actionSetup.pending.find((action) => action.reference === item.reference);
const selected = Boolean(item.savedId && actionSetup.selected.includes(item.savedId));
return (
<div key={item.reference} data-editor-assist-action={item.reference} tabIndex={-1}
aria-label={`Action setup: ${pending?.label ?? item.label}`}
className="space-y-2 rounded-lg border border-edge-strong p-3 text-text-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<p className="break-words font-semibold">{pending?.label ?? item.label}</p>
<p className="text-text-2">{pending?.type ?? item.type}</p>
{pending ? <>
<p className="break-words text-text-2">{pending.issues.length
? `Needs attention before saving: ${pending.issues.join(' ')}`
: 'Drafted for this agent. Review its setup before saving.'}</p>

Check warning on line 122 in application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
<GlassButton type="button" size="sm" className="h-auto max-w-full whitespace-normal text-left"
disabled={actionSetup.disabled || locked}

Check warning on line 124 in application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
aria-label={`Finish setup for ${pending.label} in Actions`}
onClick={() => actionSetup.onFinish(item.reference)}>
Finish setup in Actions <ArrowRight size={13} className="shrink-0" aria-hidden="true" />

Check warning on line 127 in application/v2_ui/src/components/editorAssist/EditorAskAiPanel.tsx

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
</GlassButton>
<p className="text-[11px] text-text-2">Enter credentials in the action editor, not in Ask AI.</p>
</> : item.savedId ? (
<p role="status" className="flex items-start gap-1.5 text-text-1">
<CheckCircle2 size={14} className="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
<span>{selected ? 'Action saved and selected—agent not saved yet.'
: 'Action saved—not selected for this agent.'}</span>
</p>
) : <p className="text-text-2">This action was removed from the draft.</p>}
</div>
);
})}
{record.changes.length ? (
<div className="rounded-lg border border-change-ai/40 bg-surface-1 p-2">
<p className="font-medium text-text-1" data-editor-assist-state>{stateText}</p>
Expand Down Expand Up @@ -157,25 +197,42 @@
}

/** The Ask AI panel. Rendered beside the editor's form, never inside it: the thread has its own form. */
export function EditorAskAiPanel({ assist, id, inputId, onClose }: {
export function EditorAskAiPanel({ assist, id, inputId, onClose, actionSetup, session }: {
assist: EditorAssist;
id: string;
inputId: string;
onClose: () => void;
actionSetup?: ActionSetup;
session?: AgentAssistSession;
}) {
const { thread, kind } = assist;
const quickDisabled = assist.busy || Boolean(assist.pending);
const panelRef = useRef<HTMLElement>(null);

useEffect(() => {
if (!session?.focusReference || actionSetup?.restoring) return undefined;
const reference = session.focusReference;
const frame = requestAnimationFrame(() => {
const card = Array.from(panelRef.current?.querySelectorAll<HTMLElement>('[data-editor-assist-action]') ?? [])
.find((element) => element.dataset.editorAssistAction === reference);
if (card) {
(card.querySelector<HTMLButtonElement>('button') ?? card).focus({ preventScroll: true });
session.focusReference = null;
}
});
return () => cancelAnimationFrame(frame);
}, [session, actionSetup?.restoring]);

const renderReply = (exchange: AssistExchange) => {
const record = assist.turns[exchange.id];
if (!record || record.threadKey !== thread.key) {
return <p className="whitespace-pre-wrap break-words">{exchange.reply}</p>;
}
return <EditorAssistTurnCard turnId={exchange.id} record={record} assist={assist} />;
return <EditorAssistTurnCard turnId={exchange.id} record={record} assist={assist} actionSetup={actionSetup} />;
};

return (
<aside id={id} aria-label="Ask AI" data-editor-ask-ai={kind}
<aside ref={panelRef} id={id} aria-label="Ask AI" data-editor-ask-ai={kind}
className="flex min-h-0 w-full flex-1 flex-col border-edge bg-surface-0 xl:w-[400px] xl:flex-none xl:border-l">
<div className="flex shrink-0 items-center gap-2 border-b border-edge px-3 py-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-change-ai-soft text-change-ai">
Expand Down Expand Up @@ -215,6 +272,7 @@
placeholder="Ask for a change or a question."
emptyState={<p>{EMPTY_STATE[kind]}</p>}
composerClassName="mt-2"
logPosition={session}
composerNote={assist.waitSeconds > 0 ? (
<p className="mt-2 text-[11px] text-text-3" data-editor-assist-wait>
{`You can send again in ${assist.waitSeconds} s.`}
Expand Down
46 changes: 38 additions & 8 deletions application/v2_ui/src/components/editorAssist/useEditorAssist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// POST /api/agents/assist or /api/actions/assist, and what it does with each answer.
//
// The thread is the shared assist thread in local mode, so the conversation lasts only while the
// editor is open. Each editor describes its draft through `buildView` and changes it through
// `apply`; this hook never touches the draft itself. Before anything is applied, the answer is
// editor is open, including an explicit action-setup detour. Each editor describes its draft
// through `buildView` and changes it through `apply`; this hook never touches the draft itself.
// Before anything is applied, the answer is
// checked against the draft it was sent with, and the patch is computed here rather than trusting
// the change list that came with it. Nothing is saved: the person reviews and saves the draft.

Expand All @@ -22,6 +23,7 @@ import {
} from '../../lib/editorAssist';
import { selectAssistThread, useAssistThreadStore } from '../../stores/assistThreadStore';
import { useEditorAssistStore, type EditorAssistTurnRecord } from '../../stores/editorAssistStore';
import { useBootstrapStore } from '../../stores/bootstrapStore';

const UNAVAILABLE_MESSAGE = "Ask AI isn't available for this editor.";
const NOT_APPLIED_MESSAGE = "Ask AI's changes couldn't be applied to this draft. Nothing was changed.";
Expand All @@ -40,6 +42,8 @@ export interface UseEditorAssistOptions {
readonly scope: EditorAssistScope;
/** The record being edited, such as its id, or a stable value for a new draft. */
readonly recordKey: string;
/** Reuse only for an explicit handoff back to the same live agent draft. */
readonly sessionId?: string;
/** The editor cannot take a change now, such as while it saves. */
readonly blocked: boolean;
/** The draft as the assistant sees it, read from the editor's latest state. */
Expand All @@ -51,6 +55,7 @@ export interface UseEditorAssistOptions {
readonly apply: (patch: Readonly<Record<string, unknown>>, newItems: readonly EditorAssistNewItem[]) => EditorAssistValues | null;
/** Show a section of the editor. */
readonly onJump: (section: string) => void;
readonly draftedActions?: () => NonNullable<EditorAssistTurnRecord['draftedActions']>;
}

export interface EditorAssist {
Expand All @@ -70,6 +75,8 @@ export interface EditorAssist {
readonly undo: (turnId: string) => void;
readonly jump: (section: string | null) => void;
readonly cancel: () => void;
readonly rememberAction: (reference: string, savedId: string, label: string, type: string) => void;
readonly forget: () => void;
}

/** What became of a turn's changes. */
Expand Down Expand Up @@ -108,21 +115,23 @@ interface LiveState {
buildView: UseEditorAssistOptions['buildView'];
apply: UseEditorAssistOptions['apply'];
onJump: UseEditorAssistOptions['onJump'];
draftedActions: UseEditorAssistOptions['draftedActions'];
}

export function useEditorAssist({
kind, available, scope, recordKey, blocked, buildView, apply, onJump,
kind, available, scope, recordKey, sessionId, blocked, buildView, apply, onJump, draftedActions,
}: UseEditorAssistOptions): EditorAssist {
// One thread per editor visit: a reopened editor starts a fresh conversation, so an old turn's
// Undo can never reach a draft it didn't change.
const [visit] = useState(() => makeSubmissionId());
const key = available ? `${kind}:${scope.kind}:${scope.id ?? ''}:${recordKey}:${visit}` : null;
// Undo can never reach a draft it didn't change. A setup detour preserves that same visit.
const [visit] = useState(() => sessionId ?? makeSubmissionId());
const owner = useBootstrapStore((state) => state.data?.user?.id ?? '');
const key = available ? JSON.stringify([kind, owner, scope.kind, scope.id ?? '', recordKey, visit]) : null;
const turns = useEditorAssistStore((state) => state.turns);
const retryUntil = useEditorAssistStore((state) => state.retryUntil);
const [waitSeconds, setWaitSeconds] = useState(0);

const live = useRef<LiveState>({ available, key, scope, kind, buildView, apply, onJump });
live.current = { available, key, scope, kind, buildView, apply, onJump };
const live = useRef<LiveState>({ available, key, scope, kind, buildView, apply, onJump, draftedActions });
live.current = { available, key, scope, kind, buildView, apply, onJump, draftedActions };

const send = useCallback(async (request: AssistSendRequest): Promise<AssistSendResult> => {
const sent = live.current;
Expand Down Expand Up @@ -186,6 +195,7 @@ export function useEditorAssist({
store.record(request.submissionId, explained);
return { ok: true, reply: response.reply };
}
const previousActions = new Set(now.draftedActions?.().map((item) => item.reference));
const after = now.apply(patch, newItems);
if (!after) return { ok: false, error: NOT_APPLIED_MESSAGE };
const paths = new Set([...Object.keys(patch), ...Object.keys(currentView.values), ...Object.keys(after)]);
Expand All @@ -201,6 +211,7 @@ export function useEditorAssist({
entries,
newItems,
warnings: changes.length ? response.warnings : [],
draftedActions: now.draftedActions?.().filter((item) => !previousActions.has(item.reference)) ?? [],
});
return { ok: true, reply: response.reply };
}, []);
Expand Down Expand Up @@ -285,6 +296,23 @@ export function useEditorAssist({
if (pending) thread.cancel(pending.id);
};

const rememberAction = (reference: string, savedId: string, label: string, type: string) => {
if (!key) return;
for (const [id, record] of Object.entries(useEditorAssistStore.getState().turns)) {
if (record.threadKey !== key || !record.draftedActions?.some((item) => item.reference === reference)) continue;
useEditorAssistStore.getState().update(id, (current) => ({
...current,
draftedActions: current.draftedActions?.map((item) => item.reference === reference ? { ...item, savedId, label, type } : item),
}));
}
};

const forget = () => {
if (!key) return;
useEditorAssistStore.getState().forgetThread(key);
useAssistThreadStore.getState().forgetThread(key);
};

return {
kind,
available,
Expand All @@ -298,5 +326,7 @@ export function useEditorAssist({
undo,
jump,
cancel,
rememberAction,
forget,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function AiEditedBadge() {
* because an editor saves one record as a whole.
*/
export function WorkspaceEditorFrame({
title, description, icon: TitleIcon, iconNode, backTo, sections, dirty, saving, readOnly = false, error,
title, description, icon: TitleIcon, iconNode, backTo, backLabel = 'Back', sections, dirty, saving, readOnly = false, error,
onSave, onDiscard, saveLabel = 'Save changes', actions, saveDisabled = false, initialSection,
sidePanel, sidePanelOpen = false, aiChangedSections, locked = false, lockBanner, jumpTo,
}: {
Expand All @@ -91,6 +91,7 @@ export function WorkspaceEditorFrame({
/** The record's own icon, such as an agent's uploaded image; shown in place of `icon` when given. */
iconNode?: ReactNode;
backTo: string;
backLabel?: string;
sections: WorkspaceEditorSection[];
dirty: boolean;
saving: boolean;
Expand Down Expand Up @@ -282,7 +283,7 @@ export function WorkspaceEditorFrame({
}}>
<header className="shrink-0 border-b border-edge pb-4">
<GlassButton type="button" size="sm" variant="ghost" className="-ml-2" onClick={() => navigate(backTo)}>
<ArrowLeft size={15} /> Back
<ArrowLeft size={15} /> {backLabel}
</GlassButton>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-[1_1_20rem] items-start gap-3">
Expand Down
7 changes: 6 additions & 1 deletion application/v2_ui/src/lib/assistThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export function makeSubmissionId(): string {
return id;
}
}
return `assist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
// getRandomValues also works on HTTP development hosts without randomUUID.
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') {
throw new Error('Secure randomness is unavailable for AI-assist identifiers.');
}
const bytes = crypto.getRandomValues(new Uint8Array(16));
return `assist-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
}

export function blockThreadKey(
Expand Down
Loading
Loading