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

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
upsertSavedView,
} from '../../lib/documentSavedViews';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { useChatStore } from '../../stores/chatStore';
import { useUserSettingsStore } from '../../stores/userSettingsStore';
import { toast } from '../../stores/toastStore';
import { EmptyState, GlassButton, Skeleton } from '../ui/primitives';
Expand Down Expand Up @@ -1098,6 +1099,12 @@
contextDocuments: documents.map((document) => ({ document, scope })),
contextTags: tags.map((name) => ({ name, scope })),
};
// Always a brand-new chat, as the classic interface's page load into chat is.
// The chat store outlives a route change, so navigating alone opened whichever
// conversation was last open -- busy or not -- and attached these documents to

Check warning on line 1104 in application/v2_ui/src/components/documents/DocumentExplorer.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.
// it. Leaving it detaches rather than cancels, so an answer still being written
// there finishes and is saved.
useChatStore.getState().startNewConversation();

Check warning on line 1107 in application/v2_ui/src/components/documents/DocumentExplorer.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.
navigate(`/chat?${handoff}`, { state });
} catch (cause) {
if (!controller.signal.aborted && mounted.current) toast.error(errorMessage(cause, 'Could not confirm the selected documents for chat.'));
Expand Down
14 changes: 9 additions & 5 deletions application/v2_ui/src/lib/orchestrationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
import { choosePlanSubmissionId, rememberPlanSubmission } from './planSubmissionIds';
import { canonicalPlanReferences, PlanReferenceError, type PlanReference } from './planReferences';
import { announceCompletedReply } from './replyEvents';
import { useChatStore } from '../stores/chatStore';
import { currentConversationEpoch, useChatStore } from '../stores/chatStore';

Check warning on line 63 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.
import {
selectEdits,
selectCanEditPlan,
Expand Down Expand Up @@ -187,9 +187,10 @@
/**
* Create the conversation a first message needs, or pass an existing one through.
*
* Mirrors the minimal half of `sendMessage`'s creation: claim `activeConversationId` only if
* nothing has been opened in the round trip, so a reader who opens another thread meanwhile keeps
* their place while this turn still attaches to the conversation it created.
* Mirrors the minimal half of `sendMessage`'s creation: claim `activeConversationId` only if the

Check warning on line 190 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.
* reader is still in the new chat this was sent from, so a reader who opens another thread -- or
* starts another new chat -- meanwhile keeps their place while this turn still attaches to the
* conversation it created.

Check warning on line 193 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.
*/
async function ensureConversation(
conversationId: string | null,
Expand All @@ -198,10 +199,13 @@
if (conversationId) {
return conversationId;
}
// Taken before the round trip: New chat leaves the id null too, so only the epoch says the
// reader has moved on to a different new chat.
const epoch = currentConversationEpoch();

Check warning on line 204 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.
try {
const created = await createConversation(message);
const chat = useChatStore.getState();
if (chat.activeConversationId === null) {
if (chat.activeConversationId === null && currentConversationEpoch() === epoch) {

Check warning on line 208 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.
useChatStore.setState({
activeConversationId: created.conversation_id,
activeConversationKind: 'personal',
Expand Down
4 changes: 4 additions & 0 deletions application/v2_ui/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { Link } from 'react-router-dom';
import { MessageSquarePlus } from 'lucide-react';
import { useBootstrapStore } from '../stores/bootstrapStore';
import { useChatStore } from '../stores/chatStore';
import { useUiStore } from '../stores/uiStore';
import { AdminMarkdown } from '../components/admin/AdminMarkdown';

Expand Down Expand Up @@ -72,8 +73,11 @@
/>
) : null}

{/* A fresh chat, as the label promises. The chat store outlives a route change,
so following the link alone reopened whichever conversation was last open. */}

Check warning on line 77 in application/v2_ui/src/pages/HomePage.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.
<Link
to="/chat"
onClick={() => useChatStore.getState().startNewConversation()}

Check warning on line 80 in application/v2_ui/src/pages/HomePage.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.
className="inline-flex items-center gap-2 rounded-xl bg-accent px-6 py-3 text-base font-medium text-on-accent transition-colors hover:bg-accent-hover"
>
<MessageSquarePlus size={18} />
Expand Down
4 changes: 4 additions & 0 deletions application/v2_ui/src/pages/workspace/TagsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import type { GroupWorkspaceContext } from '../../lib/workspaceContext';
import { groupWorkspacePath } from '../../lib/groupWorkspaceNavigation';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { useChatStore } from '../../stores/chatStore';
import { GlassButton, EmptyState, Skeleton } from '../../components/ui/primitives';
import { SectionError, SectionIntro, SectionSearch } from '../../components/workspace/primitives';
import { errorMessage } from '../../components/workspace/useSectionResource';
Expand Down Expand Up @@ -405,6 +406,9 @@
const state: ContextHandoffState = {
contextTags: [{ name: tag.name, scope }],
};
// A brand-new chat, for the same reason the documents hand-off starts one: otherwise
// the tag lands in whichever conversation was last open.

Check warning on line 410 in application/v2_ui/src/pages/workspace/TagsSection.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.getState().startNewConversation();
navigate(`/chat?${query}`, { state });
};

Expand Down
77 changes: 71 additions & 6 deletions application/v2_ui/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,36 @@ let streamingConversationId: string | null = null;
*/
let streamingConversationKind: ConversationKind = 'personal';

/**
* Conversations whose orchestration turn currently holds the streaming surface.
*
* 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
* `beginOrchestrationTurn` and gives it back in `settleOrchestrationTurn`, but both only write
* while that conversation is on screen. Relying on that alone, leaving a conversation mid-turn
* stranded the flag on whatever came next: a new chat showed the old turn thinking, offered a
* 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.
*/
const orchestrationSurfaces = new Set<string>();

/**
* Bumped each time the reader starts a new chat or opens a conversation.
*
* A first message creates its conversation in a round trip, then claims the screen only if the
* reader is still in the new chat it was sent from. `activeConversationId === null` cannot say
* that on its own: clicking New chat during the round trip leaves it null as well, and the old
* question used to take over the fresh chat. Comparing the epoch from before the round trip can.
*/
let conversationEpoch = 0;

/** The current epoch, for the creation path that lives outside this store (orchestration). */
export function currentConversationEpoch(): number {
return conversationEpoch;
}

/**
* Stop reading the in-flight stream without asking the server to stop producing it.
*
Expand All @@ -888,9 +918,10 @@ function detachActiveStream(): void {
activeStreamController.abort();
activeStreamController = null;
streamingConversationId = null;
// Included here rather than left to callers: neither `selectConversation` nor
// `startNewConversation` clears `streaming` itself, so dropping it would leave the
// composer stuck showing Stop for a stream that is no longer being read.
// Included here rather than left to callers: `stopStreaming` has no state reset of its
// own, so dropping it would leave the composer stuck showing Stop for a stream that is no
// longer being read. `selectConversation` and `startNewConversation` also set `streaming`
// themselves, because an orchestration turn holds it without a controller to detach.
useChatStore.setState({ streaming: false, streamingContent: '', reconnectPhase: null });
}

Expand Down Expand Up @@ -2084,6 +2115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// keep delivering the previous conversation's messages into this one.
stopCollaborationEvents();
useCollaborationStore.getState().reset();
conversationEpoch += 1;

// Known from the rail row when there is one, which is the common case and costs no
// request. A conversation opened from a link is not in the list yet, so its kind is
Expand All @@ -2104,6 +2136,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
analysisTurnRevision: analysisConversationChanged ? null : get().analysisTurnRevision,
messages: [],
messagesError: null,
// Anything streaming belonged to the conversation being left. An orchestration turn
// still running in this tab for the one being opened gets its Thinking state and
// Stop back, which also keeps the composer from sending into a busy turn; its later
// thoughts and its settle land here as usual. A chat stream is picked back up by
// `resumeChatStream` below.
streaming: conversationId ? orchestrationSurfaces.has(conversationId) : false,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -2290,6 +2328,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
detachActiveStream();
stopCollaborationEvents();
useCollaborationStore.getState().reset();
conversationEpoch += 1;

// The conversation row is created by the server on first send, so a new chat is
// purely a local reset until then. This avoids leaving empty conversations behind
Expand All @@ -2306,7 +2345,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
// that already exists, so there is no way to start one shared.
activeConversationKind: null,
messages: [],
// A load still in flight for the conversation being left returns without touching
// this once it sees the reader has moved on, so the new chat would otherwise sit
// behind loading placeholders instead of its empty state.
messagesLoading: false,
messagesError: null,
// Cleared here and not only by `detachActiveStream`, which knows about chat streams
// alone. An orchestration turn holds this flag without one, and its settle is skipped
// 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,
streamingContent: '',
thoughts: [],
streamingReasoningAdjustments: [],
Expand Down Expand Up @@ -2723,6 +2771,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
// one: a shared conversation always already exists, because sharing is done to a
// conversation rather than at the moment of writing into one.
if (!conversationId) {
// Taken before the round trip: New chat leaves `activeConversationId` null too, so
// null alone cannot say the reader is still in the chat this was sent from.
const epoch = conversationEpoch;
try {
const created = await createConversation(trimmed);
conversationId = created.conversation_id;
Expand All @@ -2732,8 +2783,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
// the interface stays where they put it instead of snapping back to a chat
// they have already left. Claiming `activeConversationId` unconditionally
// also let the thread they had just opened render its messages under this
// new one, because the list is keyed on whatever is active.
if (get().activeConversationId === null) {
// new one, because the list is keyed on whatever is active. Starting another
// new chat is the same case, which the epoch is what tells apart.
if (get().activeConversationId === null && conversationEpoch === epoch) {
set({ activeConversationId: conversationId, activeConversationKind: 'personal' });
// Mirrored like every other write to this field. Today the conversation just
// created can only be personal — this branch is reached only when nothing was
Expand Down Expand Up @@ -3058,10 +3110,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
let conversationId = get().activeConversationId;
const isNewConversation = !conversationId;
if (!conversationId) {
// As in sendMessage: only the epoch tells "still in this new chat" apart from
// "started another one" while the conversation is being created.
const epoch = conversationEpoch;
try {
const created = await createConversation(trimmed);
conversationId = created.conversation_id;
if (get().activeConversationId === null) {
if (get().activeConversationId === null && conversationEpoch === epoch) {
set({ activeConversationId: conversationId, activeConversationKind: 'personal' });
useCollaborationStore.getState().setActiveConversation(conversationId);
}
Expand Down Expand Up @@ -3158,6 +3213,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
beginOrchestrationTurn: (conversationId, text, addUserMessage = true, turnId, promptInfo) => {
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);
// 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 @@ -3237,6 +3295,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
},

settleOrchestrationTurn: (conversationId, outcome) => {
// Released whether or not the conversation is on screen, so reopening it later does not
// bring back a Thinking state for a turn that has already finished.
orchestrationSurfaces.delete(conversationId);
// The reader is elsewhere: there is no streaming surface of this conversation's to
// resolve, and the run's own record in the orchestration store is what remembers it ran.
if (get().activeConversationId !== conversationId) {
Expand Down Expand Up @@ -3394,6 +3455,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!conversationChanged && !turnChanged) {
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);
}
set((state) => {
const messages = state.messages.map((message) => {
if (message.conversation_id !== fromConversationId) {
Expand Down
7 changes: 5 additions & 2 deletions docs/explanation/features/V2_GROUP_DOCUMENT_BROWSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,11 @@ these reads.

This is not a new group-only retrieval mode. The existing chat request mapping
(`chat_type=user`, `doc_scope=all`, and the established active-group union)
remains unchanged. Existing conversation scope locks are not cleared or
retargeted; incompatible requests retain the conversation and show its error.
remains unchanged. From version 0.261.226, choosing **Chat** always starts a
brand-new conversation, so the selection is never added to whichever
conversation happened to be open. That conversation's scope lock is not cleared
or retargeted, and a reply still being written there finishes in the
background ([V2_NEW_CHAT_RESET_FIX.md](../fixes/V2_NEW_CHAT_RESET_FIX.md)).

## File structure

Expand Down
Loading
Loading