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.255"
VERSION = "0.261.256"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
29 changes: 26 additions & 3 deletions application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import { ElicitationCard } from './ElicitationCard';
import {
selectActiveTurn,
selectActiveTurnRunInFlight,
selectElicitation,
selectPlan,
useOrchestrationStore,
Expand Down Expand Up @@ -1263,11 +1264,27 @@
* A recovered stream is deliberately made to look ordinary once it is flowing again. The
* interruption is worth a brief note, but leaving "Reconnecting" under an answer that is
* actively arriving reads as a stall, which is the opposite of what is happening.
*
* An orchestrated turn shows one indicator at a time. While it plans, this bubble is the only
* sign of work, and it says "Planning" so the plan card that replaces it reads as the result. While
* an approved plan runs, the plan card already shows its progress, so this bubble draws nothing
* until the answer arrives and then only the answer. Its run notices stay with the finished
* message's reasoning steps. If the card is ever not drawing the run, the bubble shows as usual,
* so a run is never left with no indicator at all.
*/
function StreamingBubble() {
const { streamingContent, streamingReasoningAdjustments, thoughts, reconnectPhase } = useChatStore();
const {
streamingContent,
streamingReasoningAdjustments,
thoughts,
reconnectPhase,
orchestrationSurface,
activeConversationId,

Check warning on line 1282 in application/v2_ui/src/components/chat/MessageList.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.
} = useChatStore();
const collaborative = useChatStore((state) => state.activeConversationKind === 'collaborative');
const chatWidth = useUiStore((state) => state.chatWidth);
const runCardShowsProgress = useOrchestrationStore((state) =>
selectActiveTurnRunInFlight(state, activeConversationId ?? ''));

Check warning on line 1287 in application/v2_ui/src/components/chat/MessageList.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.
const [showReconnectedNote, setShowReconnectedNote] = useState(false);

useEffect(() => {
Expand All @@ -1282,6 +1299,12 @@
}, [reconnectPhase]);

const connecting = reconnectPhase === 'connecting';
const runShownOnCard = orchestrationSurface === 'running' && runCardShowsProgress;
const activityLabel = orchestrationSurface === 'planning' ? 'Planning' : 'Thinking';

if (runShownOnCard && !streamingContent) {
return null;
}

// In a shared conversation the activity line says the request is running, for everyone
// in it, so the bubble only appears once there is an answer to show.
Expand All @@ -1307,7 +1330,7 @@
</p>
)}
<ReasoningAdjustmentNotice adjustments={streamingReasoningAdjustments} />
<ThoughtsPanel thoughts={thoughts} live />
{!runShownOnCard && <ThoughtsPanel thoughts={thoughts} live />}
{streamingContent ? (
<AssistantMarkdown content={streamingContent} streaming />
) : (
Expand All @@ -1321,7 +1344,7 @@
/>
))}
</span>
{connecting ? 'Reconnecting' : 'Thinking'}
{connecting ? 'Reconnecting' : activityLabel}
</span>
)}
</div>
Expand Down
10 changes: 5 additions & 5 deletions application/v2_ui/src/components/chat/OrchestrationPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ import {
import {
applyPlanEdits,
describePlanner,
describeRunProgress,
isPlanRunnable,
isPlanTerminal,
planBindingIssues,
planHasApprovalFloor,
planRequiresApproval,
stepRoleLabel,
summarizePlan,
} from '../../lib/orchestrationPlan';
import {
Expand Down Expand Up @@ -236,7 +236,9 @@ export function OrchestrationPlanCard({
}

// Running: a compact progress line. The full step list is a click away in the drawer, so this
// stays a single row rather than duplicating it.
// stays a single row rather than duplicating it. It is the run's only progress indicator, as
// the streaming bubble stays out of the way until there is an answer, so its status line says
// what the run is doing in words.
if (runInFlight || waiting) {
const total = summary.step_count;
let completed = 0;
Expand All @@ -249,9 +251,7 @@ export function OrchestrationPlanCard({
completed += 1;
}
}
const currentStep = editedPlan?.steps.find((step) => stepRuntime[step.step_id]?.status === 'running');
const progress = waiting ? 'Waiting for results'
: currentStep ? stepRoleLabel(currentStep, true) : null;
const progress = describeRunProgress(editedPlan?.steps ?? [], stepRuntime, waiting);
return (
<div className="my-3 rounded-2xl border border-edge-strong bg-surface-sunken px-3 py-2">
<ReasoningAdjustmentNotice adjustments={plan.reasoning_adjustments} />
Expand Down
5 changes: 3 additions & 2 deletions application/v2_ui/src/lib/orchestrationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,9 @@

const context = turnContexts.get(scopeKey(conversationId, turnId));
// Enter the streaming state for the answer without a second user bubble — the question is
// already in the thread from planning.
useChatStore.getState().beginOrchestrationTurn(conversationId, '', false);
// already in the thread from planning. Taken as the run phase: the plan card shows a run's
// progress, so the streaming bubble stays out of the way until there is an answer to show.
useChatStore.getState().beginOrchestrationTurn(conversationId, '', false, undefined, undefined, 'running');

Check warning on line 770 in application/v2_ui/src/lib/orchestrationController.ts

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.

const controller = new AbortController();
activeControllers.get(conversationId)?.abort();
Expand Down
53 changes: 53 additions & 0 deletions application/v2_ui/src/lib/orchestrationPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,59 @@
return ROLE_LABELS[role][progress ? 'progress' : 'label'];
}

/** Step statuses after which a step reports nothing more in this run. */
const SETTLED_STEP_STATUSES: ReadonlySet<StepStatus> = new Set<StepStatus>([
'completed', 'partial', 'skipped', 'failed', 'cancelled',

Check warning on line 909 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
]);

/**
* The running plan card's one-line account of what the run is doing, or null for nothing new.
*
* The card is the only progress shown while a plan runs, so this line has to say what is
* happening in words: which kind of work the running step is and what it is called, that the run
* is starting or finishing, or that it is waiting. A bare role label such as "Reasoning" did not

Check warning on line 917 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
* say what was being reasoned about. The server sends the answer in one piece once every step has
* settled, so the quiet gap before it is named rather than left blank. Disabled steps never run and

Check warning on line 919 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
* are ignored. Between two steps, which lasts only as long as the executor takes to start the
* next, there is nothing new to say.
*/
export function describeRunProgress(
steps: readonly OrchestrationStep[],
runtime: Readonly<Record<string, { status: StepStatus } | undefined>>,
waiting = false,
): string | null {
if (waiting) {
return 'Waiting for results';
}
const enabled = steps.filter((step) => step.enabled);
const statusOf = (step: OrchestrationStep): StepStatus | undefined => runtime[step.step_id]?.status;
const running = enabled.find((step) => statusOf(step) === 'running');
if (running) {
const role = stepRoleLabel(running, true);

Check warning on line 935 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
const title = running.title?.trim();
if (role && title) {

Check warning on line 937 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
return `${role}: ${title}`;

Check warning on line 938 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
}
return role || title || 'Running';

Check warning on line 940 in application/v2_ui/src/lib/orchestrationPlan.ts

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.
}
if (enabled.some((step) => statusOf(step) === 'waiting')) {
return 'Waiting for results';
}
if (enabled.length > 0 && enabled.every((step) => {
const status = statusOf(step);
return status !== undefined && SETTLED_STEP_STATUSES.has(status);
})) {
return 'Preparing the answer';
}
if (enabled.every((step) => {
const status = statusOf(step);
return status === undefined || status === 'pending';
})) {
return 'Starting';
}
return null;
}

export interface OrchestrationStepGroup {
key: string;
label: string | null;
Expand Down
60 changes: 52 additions & 8 deletions application/v2_ui/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@ export type { ConversationKind };
*/
export type ReconnectPhase = 'connecting' | 'reconnected' | null;

/**
* Which part of an orchestrated turn holds the streaming surface.
*
* `planning` is the planner working before a plan or question exists, and `running` is an approved
* plan executing. They are told apart because they draw differently. While a turn plans, the
* streaming bubble is the only sign of progress. A run's progress is already on the plan card, so a
* bubble beside it only repeated it.
*/
export type OrchestrationSurfacePhase = 'planning' | 'running';

export interface ComposerOptions {
/**
* The picker's selection key for the chosen model, NOT its deployment name.
Expand Down Expand Up @@ -352,6 +362,16 @@ interface ChatState {
* the first state for the whole reattached stream makes working output look stalled.
*/
reconnectPhase: ReconnectPhase;
/**
* The orchestration phase holding the open conversation's `streaming` flag, or null when the
* flag belongs to an ordinary chat stream or nothing is streaming.
*
* `streaming` alone cannot say whose it is. A chat message can be sent while an orchestration
* run waits or is being reconciled in the same conversation, and that reply must still show
* Thinking, so the owner is recorded when the flag is taken rather than guessed from the
* orchestration store.
*/
orchestrationSurface: OrchestrationSurfacePhase | null;

/** Right-hand drawer state. Null means closed. */
drawerMode: DrawerMode;
Expand Down Expand Up @@ -453,14 +473,17 @@ interface ChatState {
* `beginOrchestrationTurn` returns the optimistic user bubble's id so the controller can hand
* it back on completion for reconciliation with the server's persisted id. Passing
* `addUserMessage: false` re-enters the thinking state for a re-plan without adding a second
* bubble, because the user's question is already in the thread from the first plan.
* bubble, because the user's question is already in the thread from the first plan. `phase`
* says which part of the turn takes the surface: a plan by default, or `running` for an
* approved plan, whose progress the plan card shows instead of the streaming bubble.
*/
beginOrchestrationTurn: (
conversationId: string,
text: string,
addUserMessage?: boolean,
turnId?: string,
promptInfo?: Json | null,
phase?: OrchestrationSurfacePhase,
) => string;
pushOrchestrationThought: (conversationId: string, event: RunStreamEvent) => void;
pushOrchestrationContent: (conversationId: string, accumulated: string) => void;
Expand Down Expand Up @@ -873,7 +896,7 @@ let streamingConversationId: string | null = null;
let streamingConversationKind: ConversationKind = 'personal';

/**
* Conversations whose orchestration turn currently holds the streaming surface.
* Conversations whose orchestration turn currently holds the streaming surface, and in which phase.
*
* A plan or run is driven by its own controller, not `activeStreamController`, so
* `detachActiveStream` knows nothing about it. The turn takes the shared `streaming` flag in
Expand All @@ -883,9 +906,10 @@ let streamingConversationKind: ConversationKind = 'personal';
* Stop with nothing to stop, and refused to send until the page was reloaded.
*
* Kept per conversation, whatever is on screen, so leaving can drop the flag and reopening a
* conversation whose turn is still running in this tab can put it back.
* conversation whose turn is still running in this tab can put it back. The phase comes back with
* it, so a reopened run still shows its progress on the plan card alone.
*/
const orchestrationSurfaces = new Set<string>();
const orchestrationSurfaces = new Map<string, OrchestrationSurfacePhase>();

/**
* Bumped each time the reader starts a new chat or opens a conversation.
Expand Down Expand Up @@ -1550,6 +1574,7 @@ async function resumeChatStream(conversationId: string): Promise<boolean> {

set({
streaming: true,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -2069,6 +2094,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
streamError: null,
streamAuthUrl: null,
reconnectPhase: null,
orchestrationSurface: null,

drawerMode: null,
metadata: null,
Expand Down Expand Up @@ -2176,6 +2202,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// thoughts and its settle land here as usual. A chat stream is picked back up by
// `resumeChatStream` below.
streaming: conversationId ? orchestrationSurfaces.has(conversationId) : false,
orchestrationSurface: conversationId ? orchestrationSurfaces.get(conversationId) ?? null : null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -2389,6 +2416,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// once its conversation is off screen, so the new chat kept the old turn's Thinking
// state and a Stop button with nothing to stop. The turn itself keeps running.
streaming: false,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -3182,6 +3210,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
],
streaming: true,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -3252,12 +3281,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},

beginOrchestrationTurn: (conversationId, text, addUserMessage = true, turnId, promptInfo) => {
beginOrchestrationTurn: (
conversationId,
text,
addUserMessage = true,
turnId,
promptInfo,
phase = 'planning',
) => {
const trimmed = text.trim();
const pendingUserMessageId = addUserMessage ? `pending-user-${Date.now()}` : '';
// Recorded whether or not the conversation is on screen, so opening it while this turn
// is still in flight shows it working.
orchestrationSurfaces.add(conversationId);
orchestrationSurfaces.set(conversationId, phase);
// Guarded on the open conversation, exactly like sendMessage's optimistic write: a run
// started here keeps going after the reader opens another thread, and its question must
// not appear inside that other thread.
Expand Down Expand Up @@ -3287,6 +3323,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
]
: state.messages,
streaming: true,
orchestrationSurface: phase,
analysisTurnRevision: state.analysisContextRevision,
streamingContent: '',
thoughts: [],
Expand Down Expand Up @@ -3353,6 +3390,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// the next turn's streaming bubble.
set({
streaming: false,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand All @@ -3364,6 +3402,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (outcome.status === 'failed' && !('event' in outcome)) {
set({
streaming: false,
orchestrationSurface: null,
streamingContent: '',
reconnectPhase: null,
streamError: outcome.error,
Expand Down Expand Up @@ -3392,6 +3431,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
]
: state.messages,
streaming: false,
orchestrationSurface: null,
streamingContent: '',
reconnectPhase: null,
}));
Expand Down Expand Up @@ -3462,6 +3502,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
finalMessage as CollaborationMessage,
),
streaming: false,
orchestrationSurface: null,
streamingContent: '',
reconnectPhase: null,
streamError: null,
Expand Down Expand Up @@ -3498,8 +3539,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
return;
}
// The turn's claim on the streaming surface follows it to the id the server named.
if (conversationChanged && orchestrationSurfaces.delete(fromConversationId)) {
orchestrationSurfaces.add(toConversationId);
const surfacePhase = orchestrationSurfaces.get(fromConversationId);
if (conversationChanged && surfacePhase && orchestrationSurfaces.delete(fromConversationId)) {
orchestrationSurfaces.set(toConversationId, surfacePhase);
}
set((state) => {
const messages = state.messages.map((message) => {
Expand Down Expand Up @@ -3709,6 +3751,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
set({
streaming: true,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -3765,6 +3808,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
set({
streaming: true,
orchestrationSurface: null,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down
Loading
Loading