Skip to content
Open
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
9 changes: 7 additions & 2 deletions frontend/src/ai/OpenAIContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import OpenAI from "openai";
import { useOpenSecret } from "@opensecret/react";
import { createAccountBoundChatFetch } from "@/services/chatAccountCredential";
import { OpenAIContext } from "./OpenAIContextDef";

export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
Expand All @@ -8,7 +9,7 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
throw new Error("VITE_OPEN_SECRET_API_URL must be set");
}

const { aiCustomFetch } = useOpenSecret();
const { aiCustomFetch, auth } = useOpenSecret();
const access_token = window.localStorage.getItem("access_token");

// If we're not logged in we can't set up openai
Expand All @@ -24,7 +25,11 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
defaultHeaders: {
"Accept-Encoding": "identity"
},
fetch: aiCustomFetch,
fetch: createAccountBoundChatFetch({
expectedUserId: auth.user?.user.id,
getAccessToken: () => window.localStorage.getItem("access_token"),
fetch: aiCustomFetch
}),
maxRetries: 0 // Disable automatic retries
});

Expand Down
78 changes: 17 additions & 61 deletions frontend/src/components/AgentMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ import {
ChatUserTurn
} from "@/components/chat/ChatTurn";
import { ChatCopyButton } from "@/components/chat/ChatCopyButton";
import {
DiscardQueuedMessageEditButton,
QUEUED_MESSAGE_EDIT_PLACEHOLDER,
QueuedComposerMessages
} from "@/components/chat/QueuedComposerMessages";
import {
continueChatComposerList,
continueChatComposerListBeforeInput
Expand Down Expand Up @@ -5062,55 +5067,16 @@ function AgentComposer({
{isExpanded ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
) : null}
{queuedMessages.length > 0 ? (
<div className="flex flex-col gap-1 px-3 pt-2">
{queuedMessages.map((item) => (
<div
key={item.queueId}
className={cn(
"flex items-center gap-1 rounded-lg bg-muted/70 px-2 py-1 text-left text-xs text-muted-foreground",
item.queueId === editingQueueId && "bg-muted text-foreground ring-1 ring-border"
)}
>
<span className="min-w-0 flex-1 truncate" title={item.text}>
{item.text || `${item.attachments?.length ?? 0} image attachment(s)`}
</span>
{onCancelQueuedMessage ? (
<button
type="button"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
onClick={() => onCancelQueuedMessage(item.queueId)}
aria-label="Remove queued message"
>
<Trash className="h-3.5 w-3.5" />
</button>
) : null}
{onEditQueuedMessage ? (
<button
type="button"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
onClick={() => onEditQueuedMessage(item.queueId)}
aria-label="Edit queued message"
>
<FilePenLine className="h-3.5 w-3.5" />
</button>
) : null}
{onSteerQueuedMessage ? (
<button
type="button"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
onClick={() => onSteerQueuedMessage(item.queueId)}
disabled={isSendDisabled}
title="Send into the current turn"
aria-label="Send queued message into the current turn"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
) : null}
</div>
))}
</div>
) : null}
<QueuedComposerMessages
items={queuedMessages}
className={onToggleExpanded ? "pr-10" : undefined}
editingQueueId={editingQueueId}
getFallbackLabel={(item) => `${item.attachments?.length ?? 0} image attachment(s)`}
onRemove={onCancelQueuedMessage}
onEdit={onEditQueuedMessage}
onSendNow={onSteerQueuedMessage}
sendNowDisabled={isSendDisabled}
/>
{!editingQueueId && draftImages.length > 0 ? (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{draftImages.map((image, index) => (
Expand Down Expand Up @@ -5149,9 +5115,7 @@ function AgentComposer({
onPaste={onImagePaste}
disabled={isSendDisabled}
placeholder={
editingQueueId
? "Edit the queued message, then send to keep its place..."
: "Ask Maple to work in this folder..."
editingQueueId ? QUEUED_MESSAGE_EDIT_PLACEHOLDER : "Ask Maple to work in this folder..."
}
className={cn(
CHAT_COMPOSER_TEXTAREA_CLASS,
Expand Down Expand Up @@ -5234,15 +5198,7 @@ function AgentComposer({

<div className="flex shrink-0 items-center self-end gap-1.5 sm:gap-2">
{editingQueueId && onDiscardQueuedMessageEdit ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 px-2 text-xs text-muted-foreground"
onClick={onDiscardQueuedMessageEdit}
>
Discard
</Button>
<DiscardQueuedMessageEditButton onDiscard={onDiscardQueuedMessageEdit} />
) : null}
{agentComposerShowsStop(isSending) ? (
<Button
Expand Down
91 changes: 88 additions & 3 deletions frontend/src/components/ChatHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,29 @@ import {
type NewChatNavigationDetail
} from "@/services/chatRuntimeNavigation";
import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
import { useOpenAI } from "@/ai/useOpenAi";
import {
resumeOrCreateChatDraftKey,
rootChatDraftKeyAfterProjectDeletion
} from "@/services/chatDraftSelection";
import { createConversationChatKey } from "@/services/chatRuntimeStore";
import {
beginAllChatRuntimeDeletionFence,
beginChatProjectRuntimeDeletionFence,
beginChatRuntimeDeletionFence
} from "@/services/chatRuntimeDeletionFence";
import {
INITIAL_CHAT_HISTORY_RETRY_COUNT,
conversationHistoryQueryKey,
initialChatHistoryPage,
shouldLoadConversationHistory
} from "@/services/chatHistoryAccountScope";
import {
cancelChatResponseForHistoryDeletion,
quiesceChatRuntimeRunsForHistoryDeletion,
settleChatRuntimeAfterHistoryCancellation
} from "@/services/chatHistoryDeletionQuiescence";
import { assertChatAccountCredential } from "@/services/chatAccountCredential";

const MAX_PROJECTS = 10;
/** Lucide default; keep sidebar list icons visually consistent. */
Expand Down Expand Up @@ -100,6 +112,7 @@ export function ChatHistoryList({
containerRef
}: ChatHistoryListProps) {
const opensecret = useOpenSecret();
const openai = useOpenAI();
const router = useRouter();
const queryClient = useQueryClient();
const { selectedProjectId, setSelectedProjectId } = useSelectedProjectState();
Expand Down Expand Up @@ -684,7 +697,22 @@ export function ChatHistoryList({
// Handle conversation deletion via API
const handleDeleteConversation = useCallback(
async (conversationId: string) => {
const expectedUserId = opensecret.auth.user?.user.id;
const releaseDeletionFence = beginChatRuntimeDeletionFence(
runtimeStore,
createConversationChatKey(conversationId)
);
try {
assertChatAccountCredential(expectedUserId);
await quiesceChatRuntimeRunsForHistoryDeletion({
store: runtimeStore,
keys: [createConversationChatKey(conversationId)],
responseOwnershipClient: openai,
cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
settleCancelledRun: (key, runToken) =>
settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
});
assertChatAccountCredential(expectedUserId);
await opensecret.deleteConversation(conversationId);

if (conversationId === currentChatId) {
Expand All @@ -704,9 +732,11 @@ export function ChatHistoryList({
}
} catch (error) {
console.error("Error deleting conversation:", error);
} finally {
releaseDeletionFence();
}
},
[currentChatId, invalidateConversationData, opensecret, runtimeStore]
[currentChatId, invalidateConversationData, openai, opensecret, runtimeStore]
);

const MAX_SELECTION = 20;
Expand Down Expand Up @@ -734,11 +764,26 @@ export function ChatHistoryList({
if (selectedIds.size === 0) return;

setIsBulkDeleting(true);
const releaseDeletionFences = Array.from(selectedIds, (id) =>
beginChatRuntimeDeletionFence(runtimeStore, createConversationChatKey(id))
);
try {
const expectedUserId = opensecret.auth.user?.user.id;
assertChatAccountCredential(expectedUserId);
const idsToDelete = Array.from(selectedIds);
const keysToDelete = idsToDelete.map((id) => createConversationChatKey(id));
const deletedIds = new Set<string>();

if (opensecret) {
await quiesceChatRuntimeRunsForHistoryDeletion({
store: runtimeStore,
keys: keysToDelete,
responseOwnershipClient: openai,
cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
settleCancelledRun: (key, runToken) =>
settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
});
assertChatAccountCredential(expectedUserId);
const result = await opensecret.batchDeleteConversations(idsToDelete);

result.data.filter((item) => item.deleted).forEach((item) => deletedIds.add(item.id));
Expand Down Expand Up @@ -768,10 +813,12 @@ export function ChatHistoryList({
} catch (error) {
console.error("Error bulk deleting chats:", error);
} finally {
for (const releaseDeletionFence of releaseDeletionFences) releaseDeletionFence();
setIsBulkDeleting(false);
}
}, [
selectedIds,
openai,
opensecret,
invalidateConversationData,
currentChatId,
Expand Down Expand Up @@ -985,13 +1032,15 @@ export function ChatHistoryList({
const handleDeleteProject = useCallback(async () => {
if (!selectedProject) return;
let replacementDispatched = false;
const projectConversationKeys = new Set<ReturnType<typeof createConversationChatKey>>();
const projectAwayFromDeletedProject = () => {
if (replacementDispatched) return;
const deletingProjectedChat =
selectedProjectId === selectedProject.id ||
(currentChatId
? runtimeStore.getActivityGroupId(createConversationChatKey(currentChatId)) ===
selectedProject.id
? projectConversationKeys.has(createConversationChatKey(currentChatId)) ||
runtimeStore.getActivityGroupId(createConversationChatKey(currentChatId)) ===
selectedProject.id
: false);
if (!deletingProjectedChat) return;

Expand All @@ -1015,7 +1064,38 @@ export function ChatHistoryList({
window.dispatchEvent(new Event("projectselected"));
};

// Until server membership is known, conservatively pause every local
// runner. Replace this short discovery fence synchronously with exact-key
// plus activity-group fences before the destructive request starts.
const releaseDiscoveryFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let releaseDeletionFence: (() => void) | null = null;
try {
const expectedUserId = opensecret.auth.user?.user.id;
assertChatAccountCredential(expectedUserId);
const projectConversations = await listAllConversations(opensecret, {
project_id: selectedProject.id
});
for (const conversation of projectConversations) {
projectConversationKeys.add(createConversationChatKey(conversation.id));
}
releaseDeletionFence = beginChatProjectRuntimeDeletionFence(
runtimeStore,
selectedProject.id,
Array.from(projectConversationKeys)
);
releaseDiscoveryFence();

await quiesceChatRuntimeRunsForHistoryDeletion({
store: runtimeStore,
keys: Array.from(projectConversationKeys),
activityGroupId: selectedProject.id,
responseOwnershipClient: openai,
cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
settleCancelledRun: (key, runToken) =>
settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
});

assertChatAccountCredential(expectedUserId);
await opensecret.deleteConversationProject(selectedProject.id);
projectAwayFromDeletedProject();

Expand All @@ -1031,16 +1111,21 @@ export function ChatHistoryList({
projectAwayFromDeletedProject();
// A selected chat in this project has now committed its replacement;
// grouped background runtimes can be aborted and discarded safely.
for (const key of projectConversationKeys) runtimeStore.delete(key);
runtimeStore.deleteActivityGroup(selectedProject.id);
}
} catch (error) {
console.error("Error deleting project:", error);
throw error;
} finally {
releaseDeletionFence?.();
releaseDiscoveryFence();
}
}, [
currentChatId,
expandedProjectId,
invalidateConversationData,
openai,
opensecret,
selectedProject,
selectedProjectId,
Expand Down
Loading
Loading