Skip to content
Closed
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 office-addin/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion office-addin/src/components/AddinChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export const AddinChatInput = forwardRef<
emailBodyFile,
emailSubject,
isLoadingEmailBody,
isBlockingLoadEmailBody,
emailThreadLoadError,
selectedAttachmentItems,
isLoadingAttachments,
Expand Down Expand Up @@ -233,10 +234,15 @@ export const AddinChatInput = forwardRef<
const shouldUseSuggestedEmailSource =
(showSuggestedEmailSource && hasSelectedEmailSource) ||
hasDroppedStagedEmails;
// Gate the composer disabled state on the short blocking window only, not
// on the full fetch duration. `isBlockingLoadEmailBody` turns false after
// the UI-block deadline so a slow or stalled Graph request doesn't freeze
// the input for the whole 20-second thread fetch timeout. The loading chip
// (driven by `isLoadingEmailBody`) stays visible for the full duration.
const isWaitingForSuggestedEmail =
showSuggestedEmailSource &&
!hasDroppedStagedEmails &&
isLoadingEmailBody &&
isBlockingLoadEmailBody &&
!emailThreadLoadError;
// Render the email-source preview whenever there is *something* to show:
// a real attachment, an in-flight attachment fetch, or the reply-context
Expand Down
98 changes: 97 additions & 1 deletion office-addin/src/hooks/__tests__/useCurrentThread.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createGraphOutlookMessageFetcher } from "../../utils/fetchOutlookMessage";
import { OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS } from "../../utils/graphRequestTimeout";
import { useCurrentThread } from "../useCurrentThread";

import type { GraphTransport } from "../../utils/fetchOutlookMessageGraph";
Expand Down Expand Up @@ -80,6 +81,7 @@ describe("useCurrentThread", () => {
expect(result.current).toEqual({
thread: null,
isLoading: false,
isBlockingLoad: false,
error: false,
});
expect(transport).not.toHaveBeenCalled();
Expand All @@ -94,6 +96,7 @@ describe("useCurrentThread", () => {
expect(result2.current).toEqual({
thread: null,
isLoading: false,
isBlockingLoad: false,
error: false,
});
expect(transport).not.toHaveBeenCalled();
Expand Down Expand Up @@ -193,6 +196,99 @@ describe("useCurrentThread", () => {
consoleWarn.mockRestore();
expect(result.current.thread).toBeNull();
expect(result.current.error).toBe(true);
// A failed fetch must not leave the UI in a blocking state.
expect(result.current.isBlockingLoad).toBe(false);
});

describe("isBlockingLoad", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("is false when fetch is disabled (no itemId/conversationId)", () => {
const transport: GraphTransport = vi.fn(async () => {
throw new Error("should not be called");
});
const { result } = renderHook(
() =>
useCurrentThread(null, "conv-1", fetchConversationMessages, {
transport,
}),
{ wrapper: createWrapper() },
);
expect(result.current.isBlockingLoad).toBe(false);
});

it("is true on initial load and flips to false after the UI-block deadline", async () => {
// A transport that never resolves — simulates a slow / stalled network.
const transport: GraphTransport = vi.fn(
() => new Promise<Response>(() => {}),
);

const { result } = renderHook(
() =>
useCurrentThread("item-1", "conv-1", fetchConversationMessages, {
transport,
}),
{ wrapper: createWrapper() },
);

// Fetch is in flight and within the deadline: input must be blocked.
expect(result.current.isLoading).toBe(true);
expect(result.current.isBlockingLoad).toBe(true);

// Advance time past the UI-block deadline but not the full fetch timeout.
await act(async () => {
vi.advanceTimersByTime(OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS);
});

// Fetch is still in flight (never resolved), but the deadline has elapsed:
// the input must be unblocked while the loading chip stays visible.
expect(result.current.isLoading).toBe(true);
expect(result.current.isBlockingLoad).toBe(false);
});

it("resets to true (re-blocks) when conversationId changes after the deadline has elapsed", async () => {
// A transport that never resolves.
const transport: GraphTransport = vi.fn(
() => new Promise<Response>(() => {}),
);

const { result, rerender } = renderHook(
({ conversationId }) =>
useCurrentThread(
"item-1",
conversationId,
fetchConversationMessages,
{
transport,
},
),
{
initialProps: { conversationId: "conv-A" },
wrapper: createWrapper(),
},
);

// Let the deadline elapse for conv-A.
await act(async () => {
vi.advanceTimersByTime(OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS);
});
expect(result.current.isBlockingLoad).toBe(false);

// Switch to a new conversation — the block deadline must reset.
await act(async () => {
rerender({ conversationId: "conv-B" });
});

// The new fetch is in flight and within the fresh deadline.
expect(result.current.isLoading).toBe(true);
expect(result.current.isBlockingLoad).toBe(true);
});
});

it("keeps cached thread data non-loading during a background refetch", async () => {
Expand Down
42 changes: 40 additions & 2 deletions office-addin/src/hooks/useCurrentThread.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useEffect, useState } from "react";

import {
OUTLOOK_GRAPH_THREAD_TIMEOUT_MS,
OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS,
runWithGraphTimeout,
} from "../utils/graphRequestTimeout";
import { fetchCurrentThread, type ParsedThread } from "../utils/parsedThread";
Expand All @@ -22,6 +23,16 @@ export interface UseCurrentThreadResult {
* leaves `thread === null` with `error === false`.
*/
error: boolean;
/**
* True while the conversation fetch is both in-flight AND within the short
* UI-block deadline ({@link OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS}).
*
* Distinct from {@link isLoading}: `isLoading` drives loading chips for the
* full fetch duration; `isBlockingLoad` gates the chat-input disabled state
* and turns false after the deadline so a slow or stalled Graph request
* does not freeze the composer for the entire 20-second fetch timeout.
*/
isBlockingLoad: boolean;
}

/**
Expand Down Expand Up @@ -97,13 +108,40 @@ export function useCurrentThread(
}
}, [query.error, query.isError]);

// Tracks whether we're within the short UI-block window for this fetch.
// Starts true; the effect below sets a timer to flip it false after the
// deadline so the chat input unblocks even when the fetch is still running.
const [isWithinBlockDeadline, setIsWithinBlockDeadline] = useState(true);

// Reset the deadline and start the timer whenever the conversation key
// changes (new item or new conversation). The cleanup clears any pending
// timer so a fast success on conv-A doesn't accidentally unblock conv-B.
useEffect(() => {
if (!enabled) return;

setIsWithinBlockDeadline(true);
const timerId = globalThis.setTimeout(() => {
setIsWithinBlockDeadline(false);
}, OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS);

return () => {
globalThis.clearTimeout(timerId);
};
}, [enabled, itemId, conversationId]);

if (!enabled) {
return { thread: null, isLoading: false, error: false };
return {
thread: null,
isLoading: false,
error: false,
isBlockingLoad: false,
};
}

return {
thread: query.data ?? null,
isLoading: query.isPending,
error: query.isError,
isBlockingLoad: query.isPending && isWithinBlockDeadline,
};
}
13 changes: 13 additions & 0 deletions office-addin/src/providers/OutlookEmailSourceProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ interface OutlookEmailSourceContextValue {
*/
isThreadEmlStale: boolean;
isLoadingEmailBody: boolean;
/**
* True while the conversation fetch is both in-flight AND within the short
* UI-block deadline. Use this (not {@link isLoadingEmailBody}) to gate the
* chat-input disabled state: `isLoadingEmailBody` drives the loading chip
* for the full fetch duration, while `isBlockingLoadEmailBody` unblocks the
* composer after the deadline so a slow or stalled Graph request does not
* freeze it for the entire 20-second fetch timeout.
*/
isBlockingLoadEmailBody: boolean;
/**
* True when the open conversation failed to load entirely (Graph fetch
* errored on the first page). Surfaced so the UI can warn the user rather
Expand Down Expand Up @@ -154,6 +163,7 @@ const defaultValue: OutlookEmailSourceContextValue = {
emailBodyFile: null,
isThreadEmlStale: false,
isLoadingEmailBody: false,
isBlockingLoadEmailBody: false,
emailThreadLoadError: false,
currentThread: null,
stagedEmails: [],
Expand Down Expand Up @@ -220,6 +230,7 @@ export function OutlookEmailSourceProvider({
const {
thread: currentThread,
isLoading: isLoadingEmailBody,
isBlockingLoad: isBlockingLoadEmailBody,
error: emailThreadLoadError,
} = useCurrentThread(
itemId,
Expand Down Expand Up @@ -566,6 +577,7 @@ export function OutlookEmailSourceProvider({
emailBodyFile,
isThreadEmlStale,
isLoadingEmailBody,
isBlockingLoadEmailBody,
emailThreadLoadError,
currentThread,
stagedEmails,
Expand Down Expand Up @@ -599,6 +611,7 @@ export function OutlookEmailSourceProvider({
dismissedAttachmentIds,
emailBodyFile,
isThreadEmlStale,
isBlockingLoadEmailBody,
isEmailBodyDismissed,
emailThreadLoadError,
isEmailBodyIncluded,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe("OutlookEmailSourceProvider — current-thread emailBodyFile", () => {
mockUseCurrentThread.mockReturnValue({
thread: makeThread(),
isLoading: false,
isBlockingLoad: false,
error: false,
});
mockUseOutlookMessageFetcher.mockReturnValue({
Expand Down Expand Up @@ -205,6 +206,72 @@ describe("OutlookEmailSourceProvider — current-thread emailBodyFile", () => {
});
});

describe("OutlookEmailSourceProvider — isBlockingLoadEmailBody", () => {
it("forwards isBlockingLoad from useCurrentThread as isBlockingLoadEmailBody", () => {
mockUseOutlookMailItem.mockReturnValue({
itemIdentity: "id-1",
mailItem: {
itemId: "item-1",
conversationId: "conv-1",
internetMessageId: "<m1@x>",
subject: "Test",
isComposeMode: false,
},
attachments: [],
isLoadingAttachments: false,
getAttachmentFile: vi.fn(),
});
mockUseCurrentThread.mockReturnValue({
thread: null,
isLoading: true,
isBlockingLoad: true,
error: false,
});
mockUseOutlookMessageFetcher.mockReturnValue({
fetcher: null,
unavailableReason: "unsupported-mode",
});

renderProvider();

expect(captured!.isLoadingEmailBody).toBe(true);
expect(captured!.isBlockingLoadEmailBody).toBe(true);
});

it("exposes isBlockingLoadEmailBody=false when the block deadline has elapsed (isLoading still true)", () => {
mockUseOutlookMailItem.mockReturnValue({
itemIdentity: "id-1",
mailItem: {
itemId: "item-1",
conversationId: "conv-1",
internetMessageId: "<m1@x>",
subject: "Test",
isComposeMode: false,
},
attachments: [],
isLoadingAttachments: false,
getAttachmentFile: vi.fn(),
});
// Simulates the state after the UI-block deadline: still loading but
// isBlockingLoad=false so the chat input is no longer disabled.
mockUseCurrentThread.mockReturnValue({
thread: null,
isLoading: true,
isBlockingLoad: false,
error: false,
});
mockUseOutlookMessageFetcher.mockReturnValue({
fetcher: null,
unavailableReason: "unsupported-mode",
});

renderProvider();

expect(captured!.isLoadingEmailBody).toBe(true);
expect(captured!.isBlockingLoadEmailBody).toBe(false);
});
});

describe("OutlookEmailSourceProvider — compose reply-context via the dispatched fetcher", () => {
function primeComposeMode() {
mockUseOutlookMailItem.mockReturnValue({
Expand All @@ -224,6 +291,7 @@ describe("OutlookEmailSourceProvider — compose reply-context via the dispatche
mockUseCurrentThread.mockReturnValue({
thread: null,
isLoading: false,
isBlockingLoad: false,
error: false,
});
}
Expand Down
15 changes: 15 additions & 0 deletions office-addin/src/utils/graphRequestTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
export const OUTLOOK_GRAPH_MESSAGE_TIMEOUT_MS = 10_000;
export const OUTLOOK_GRAPH_THREAD_TIMEOUT_MS = 20_000;

/**
* How long the chat-input composer blocks waiting for an in-flight thread
* fetch before degrading gracefully.
*
* After this deadline the input is re-enabled even if the fetch is still
* running. The loading chip stays visible so the user knows the context
* is still being resolved; if the fetch eventually completes the email will
* be included when the user sends.
*
* Chosen to be well under the full {@link OUTLOOK_GRAPH_THREAD_TIMEOUT_MS}
* so a slow or stalled upstream request doesn't freeze the composer for the
* entire 20-second timeout.
*/
export const OUTLOOK_GRAPH_THREAD_UI_BLOCK_DEADLINE_MS = 5_000;

export class OutlookGraphTimeoutError extends Error {
constructor(message: string) {
super(message);
Expand Down
Loading