Repository: nulib/dc-nextjs
Pull request #412: Add a reusable component for displaying user docs
Author: login: charlesLoder
Created: 2025-03-26T20:18:39Z | Merged: 2025-03-28T19:04:59Z

## Description

Improves UI/UX by adding a reusable component for adding documents to the conversation context

## Summary of changes

- Creates a reusable "stack" component
- Adjusts app state by adding:
  - `conversation.context` which is context that has not been added directly into the turns of the conversation
  - `conversation.turns.context` which is context that has been added into the turn
- Minor styling adjustments

## Example

Ask a question and click on "View more results"

<img width="1528" alt="image" src="https://github.com/user-attachments/assets/b9a49237-2da6-40a7-8acb-590f3685c0f5" />

- By default the input is **not** checked
- With the input unchecked, navigating "Back to conversation" works as it has prior

Next, filter on something:

<img width="1322" alt="image" src="https://github.com/user-attachments/assets/010d1360-c05a-4a26-980e-c608f21b6024" />

- when a filter is first chosen, the input is automatically checked
- there is a tooltip displaying the query and filters


Next, with the checkbox checked navigate with "Back to conversation"

<img width="1228" alt="image" src="https://github.com/user-attachments/assets/caee53bc-18fb-4a11-afca-7c8cd26dc762" />

- only the first 20 documents are passed into context
- the documents are added to the textbox, but with a dismiss button in case the user does not want to ask about the documents
- clicking dismiss removes them from the context

Next, asking a question with the docs in context shows them in question

<img width="1187" alt="image" src="https://github.com/user-attachments/assets/62daed28-0edd-4fbc-8332-2968f3358aba" />

- No tool call is displayed


Diff:
diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx
index 7865bf48..6715a967 100644
--- a/components/Chat/Chat.tsx
+++ b/components/Chat/Chat.tsx
@@ -8,7 +8,6 @@ import { StyledUnsubmitted } from "./Response/Response.styled";
 import { styled } from "@/stitches.config";
 import { useSearchState } from "@/context/search-context";
 import { v4 as uuidv4 } from "uuid";
-import InterstitialDocuments from "./Response/InterstitialDocuments";
 
 const Chat = () => {
   const { searchState, searchDispatch } = useSearchState();
@@ -48,19 +47,13 @@ const Chat = () => {
             ...conversation.turns,
             {
               question: value,
-              userDocs: conversation.latestDocs
-                ? conversation.latestDocs
-                : undefined,
+              context: conversation.context, // move the chat context to the next turn
               answer: "",
               aggregations: [],
               works: [],
             },
           ],
-          // if the user is using faceted docs in a question
-          // reset the latest docs
-          latestDocs: conversation.latestDocs
-            ? undefined
-            : conversation.latestDocs,
+          context: undefined, // clear chat context on new question
         },
       });
     }
@@ -94,17 +87,11 @@ const Chat = () => {
                 key={index}
                 question={turn.question}
                 content={turn.renderedContent}
-                userDocs={turn.userDocs}
+                context={turn.context}
                 responseCallback={handleResponseCallback}
               />
             );
           })}
-        {conversation.latestDocs && conversation.latestDocs.length > 0 && (
-          <InterstitialDocuments
-            documents={conversation.latestDocs}
-            canRemove={true}
-          />
-        )}
         <ChatConversation
           conversationCallback={handleConversationCallback}
           isStreaming={isStreaming}
diff --git a/components/Chat/Conversation.styled.tsx b/components/Chat/Conversation.styled.tsx
index 8623ae84..b160d748 100644
--- a/components/Chat/Conversation.styled.tsx
+++ b/components/Chat/Conversation.styled.tsx
@@ -1,4 +1,5 @@
 import { keyframes, styled } from "@/stitches.config";
+import { StyledStack } from "./Stack/Stack.styled";
 
 const gradientAnimation = keyframes({
   to: {
@@ -72,7 +73,7 @@ const StyledChatConversation = styled("div", {
       animation: "none !important",
       background: "$purple10",
 
-      button: {
+      ["button[type='submit']"]: {
         backgroundColor: "$purple",
         color: "$white",
       },
@@ -109,6 +110,7 @@ const StyledChatConversation = styled("div", {
       transition: "$dcAll",
       boxSizing: "border-box",
       background: "transparent",
+      position: "relative",
 
       "&::placeholder": {
         overflow: "hidden",
@@ -117,7 +119,22 @@ const StyledChatConversation = styled("div", {
       },
     },
 
-    button: {
+    [`& > ${StyledStack}`]: {
+      position: "absolute",
+      top: "calc($gr3 / 2)",
+      left: "calc($gr3 / 2)",
+      zIndex: 1,
+    },
+
+    // 40px is a too magic numbery
+    [`&:has(${StyledStack}[data-isdismissed=false])`]: {
+      textarea: {
+        textIndent: "40px",
+        "&::placeholder": {},
+      },
+    },
+
+    ["button[type='submit']"]: {
       position: "absolute",
       bottom: "$gr2",
       right: "$gr2",
@@ -134,7 +151,7 @@ const StyledChatConversation = styled("div", {
       fontSize: "$gr3",
       padding: "0 $gr2",
       gap: "$gr2",
-      zIndex: 0,
+      zIndex: 1,
       fontFamily: "$northwesternSansRegular",
 
       "&:hover, &:focus": {
diff --git a/components/Chat/Conversation.tsx b/components/Chat/Conversation.tsx
index 9dfe8723..445c520e 100644
--- a/components/Chat/Conversation.tsx
+++ b/components/Chat/Conversation.tsx
@@ -7,6 +7,7 @@ import { useEffect, useRef } from "react";
 
 import { useRouter } from "next/router";
 import { useSearchState } from "@/context/search-context";
+import Stack from "./Stack/Stack";
 
 interface ChatConversationProps {
   conversationCallback: (message: string) => void;
@@ -20,14 +21,13 @@ const ChatConversation: React.FC<ChatConversationProps> = ({
   const router = useRouter();
   const {
     searchState: { conversation },
+    searchDispatch,
   } = useSearchState();
 
   const textareaRef = useRef<HTMLTextAreaElement>(null);
   const formRef = useRef<HTMLFormElement>(null);
 
-  const textareaPlaceholder = conversation.latestDocs
-    ? "Ask about these documents"
-    : "Ask a followup question";
+  const textareaPlaceholder = "Ask a followup question";
 
   const handleScroll = () => {
     // handle scrolling
@@ -67,6 +67,16 @@ const ChatConversation: React.FC<ChatConversationProps> = ({
     formRef.current!.dataset.isFocused = isFocused;
   };
 
+  const handleStackDismiss = () => {
+    searchDispatch({
+      type: "updateConversation",
+      conversation: {
+        ...conversation,
+        context: undefined,
+      },
+    });
+  };
+
   const handleClearConversation = () => {
     const textarea = document.getElementById(
       "dc-search",
@@ -98,6 +108,14 @@ const ChatConversation: React.FC<ChatConversationProps> = ({
           onFocus={handleFocus}
           onBlur={handleFocus}
         ></textarea>
+        {conversation.context?.works &&
+          conversation.context.works.length > 0 && (
+            <Stack
+              context={conversation.context}
+              isDismissable
+              dismissCallback={handleStackDismiss}
+            />
+          )}
         <button type="submit" disabled={isStreaming}>
           {isStreaming ? (
             <>
diff --git a/components/Chat/Response/Interstitial.tsx b/components/Chat/Response/Interstitial.tsx
index 21a3c032..7382ce92 100644
--- a/components/Chat/Response/Interstitial.tsx
+++ b/components/Chat/Response/Interstitial.tsx
@@ -83,10 +83,6 @@ const ResponseInterstitial: React.FC<ResponseInterstitialProps> = ({
     case "discover_fields":
       text = `Discovering`;
       break;
-    case "retrieve_documents":
-      const docCount = input.doc_ids.length;
-      text = `Retrieving ${docCount} document${docCount > 1 ? "s" : ""}`;
-      break;
     case "search":
       text = `Searching for <strong>${input.query}</strong>`;
       action = input.query;
@@ -95,6 +91,10 @@ const ResponseInterstitial: React.FC<ResponseInterstitialProps> = ({
       console.warn("Unknown tool_start message", message);
   }
 
+  if (tool === "retrieve_documents") {
+    return <></>;
+  }
+
   return (
     <StyledInterstitialWrapper id={`interstitial-${id}`}>
       <StyledInterstitial data-testid="response-interstitial" data-tool={tool}>
diff --git a/components/Chat/Response/InterstitialDocuments.tsx b/components/Chat/Response/InterstitialDocuments.tsx
deleted file mode 100644
index a0fd9aa3..00000000
--- a/components/Chat/Response/InterstitialDocuments.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect } from "react";
-import type { SearchContextStore } from "@/types/context/search-context";
-import {
-  StyledInterstitial,
-  StyledInterstitialWrapper,
-  StyledInterstitialIcon,
-  StyledInterstitialAction,
-} from "../Response/Interstitial.styled";
-import ResponseImages from "../Response/Images";
-import { IconClear, IconSparkles } from "@/components/Shared/SVG/Icons";
-import { useSearchState } from "@/context/search-context";
-
-interface InterstitialDocumentsProps {
-  documents: NonNullable<SearchContextStore["conversation"]["latestDocs"]>;
-  canRemove: boolean;
-}
-
-const InterstitialDocuments = ({
-  documents,
-  canRemove,
-}: InterstitialDocumentsProps) => {
-  const { searchState, searchDispatch } = useSearchState();
-
-  const {
-    panel: { open, interstitial },
-    conversation,
-  } = searchState;
-
-  useEffect(() => {
-    // if the panel is being opened or there is no interstitial, exit
-    if (open || !interstitial) return;
-
-    if ("latestdocs" !== interstitial) return;
-
-    const interstitialElement = document.getElementById(
-      `interstitial-latestdocs`,
-    );
-
-    if (!interstitialElement) return;
-
-    // because the search bar is absolutely positioned,
-    // grab its height to offset the scroll position
-    const searchBar = document.getElementById("dc-search");
-    const offset = searchBar?.offsetHeight || 0;
-    const topPos =
-      interstitialElement.getBoundingClientRect().top + window.scrollY - offset;
-
-    window.scrollTo({
-      top: topPos - 25, // 25 is to offset the box-shadow
-      behavior: "smooth",
-    });
-  }, [open]);
-
-  const removeDocs = () => {
-    searchDispatch({
-      type: "updateConversation",
-      conversation: {
-        ...conversation,
-        latestDocs: undefined,
-      },
-    });
-  };
-  return (
-    <>
-      <StyledInterstitialWrapper
-        id="interstitial-latestdocs"
-        data-testid="interstitial-latestdocs"
-      >
-        <StyledInterstitial>
-          <StyledInterstitialIcon>
-            <IconSparkles />
-          </StyledInterstitialIcon>
-          Selected documents ({documents.length}):
-        </StyledInterstitial>
-        {canRemove && (
-          <StyledInterstitialAction onClick={() => removeDocs()}>
-            Remove documents <IconClear />
-          </StyledInterstitialAction>
-        )}
-      </StyledInterstitialWrapper>
-      <ResponseImages works={documents} />
-    </>
-  );
-};
-export default InterstitialDocuments;
diff --git a/components/Chat/Response/Response.styled.tsx b/components/Chat/Response/Response.styled.tsx
index e02fadb0..93e3c01d 100644
--- a/components/Chat/Response/Response.styled.tsx
+++ b/components/Chat/Response/Response.styled.tsx
@@ -86,6 +86,9 @@ const StyledQuestion = styled("header", {
   alignSelf: "flex-end",
   borderRadius: "1rem",
   background: "$purple10",
+  display: "flex",
+  alignItems: "center",
+  gap: "$gr2",
 });
 
 const StyledResponseMarkdown = styled("div", {
diff --git a/components/Chat/Response/Response.test.tsx b/components/Chat/Response/Response.test.tsx
index 52b4a59d..0ae1aa98 100644
--- a/components/Chat/Response/Response.test.tsx
+++ b/components/Chat/Response/Response.test.tsx
@@ -1,13 +1,10 @@
 import { act, render, screen } from "@/test-utils";
 
 import ChatResponse from "./Response";
-import exp from "constants";
 import { sampleWork1 } from "@/mocks/sample-work1";
 import { sampleWork2 } from "@/mocks/sample-work2";
 import useChatSocket from "@/hooks/useChatSocket";
 import { useState } from "react";
-import { work1 } from "@/mocks/work-page/work1";
-import { workManifest1 } from "@/mocks/work-page/work-manifest1";
 
 const mockSendMessage = jest.fn();
 
@@ -44,7 +41,11 @@ describe("ChatResponse component", () => {
 
   it("renders default state", () => {
     render(
-      <ChatResponse question={question} conversationRef={conversationRef} />,
+      <ChatResponse
+        question={question}
+        conversationIndex={0}
+        conversationRef={conversationRef}
+      />,
     );
 
     const article = screen.getByRole("article");
@@ -77,7 +78,11 @@ describe("ChatResponse component", () => {
     });
 
     render(
-      <ChatResponse question={question} conversationRef={conversationRef} />,
+      <ChatResponse
+        question={question}
+        conversationIndex={0}
+        conversationRef={conversationRef}
+      />,
     );
 
     const content = screen.getByTestId("response-content");
@@ -130,7 +135,11 @@ describe("ChatResponse component", () => {
     });
 
     render(
-      <ChatResponse question={question} conversationRef={conversationRef} />,
+      <ChatResponse
+        question={question}
+        conversationIndex={0}
+        conversationRef={conversationRef}
+      />,
     );
 
     const content = screen.getByTestId("response-content");
@@ -179,7 +188,11 @@ describe("ChatResponse component", () => {
     });
 
     render(
-      <ChatResponse question={question} conversationRef={conversationRef} />,
+      <ChatResponse
+        question={question}
+        conversationIndex={0}
+        conversationRef={conversationRef}
+      />,
     );
 
     const content = screen.getByTestId("response-content");
@@ -233,7 +246,11 @@ describe("ChatResponse component", () => {
     });
 
     render(
-      <ChatResponse question={question} conversationRef={conversationRef} />,
+      <ChatResponse
+        question={question}
+        conversationIndex={0}
+        conversationRef={conversationRef}
+      />,
     );
 
     const content = screen.getByTestId("response-content");
diff --git a/components/Chat/Response/Response.tsx b/components/Chat/Response/Response.tsx
index dc36d30a..f1ccf762 100644
--- a/components/Chat/Response/Response.tsx
+++ b/components/Chat/Response/Response.tsx
@@ -15,13 +15,13 @@ import useChatSocket from "@/hooks/useChatSocket";
 import { useSearchState } from "@/context/search-context";
 import { v4 as uuidv4 } from "uuid";
 import type { Turn } from "@/types/context/search-context";
-import InterstitialDocuments from "./InterstitialDocuments";
+import Stack from "../Stack/Stack";
 
 interface ChatResponseProps {
   conversationIndex: number;
   conversationRef?: string;
   question: string;
-  userDocs: Turn["userDocs"];
+  context?: Turn["context"];
   content?: React.JSX.Element;
   responseCallback?: (response?: any) => void;
 }
@@ -30,7 +30,7 @@ const ChatResponse: React.FC<ChatResponseProps> = ({
   conversationIndex,
   conversationRef,
   question,
-  userDocs,
+  context,
   content,
   responseCallback,
 }) => {
@@ -48,7 +48,7 @@ const ChatResponse: React.FC<ChatResponseProps> = ({
         question,
         authToken,
         conversationRef,
-        userDocs,
+        context?.works,
       );
       sendMessage(preparedQuestion);
     }
@@ -161,10 +161,12 @@ const ChatResponse: React.FC<ChatResponseProps> = ({
       data-ref={conversationRef}
       data-question={question}
     >
-      <StyledQuestion>{question}</StyledQuestion>
-      {userDocs && userDocs.length > 0 && (
-        <InterstitialDocuments documents={userDocs} canRemove={false} />
-      )}
+      <StyledQuestion>
+        {question}
+        {context?.works && context.works.length > 0 && (
+          <Stack context={context} isDismissable={false} />
+        )}
+      </StyledQuestion>
       <div data-testid="response-content">
         {content ? (
           content
diff --git a/components/Chat/Stack/Stack.styled.tsx b/components/Chat/Stack/Stack.styled.tsx
new file mode 100644
index 00000000..a853980a
--- /dev/null
+++ b/components/Chat/Stack/Stack.styled.tsx
@@ -0,0 +1,105 @@
+import { styled } from "@/stitches.config";
+import { gr } from "@/styles/sizes";
+import { IconClear } from "@/components/Shared/SVG/Icons";
+
+const StyledStack = styled("div", {
+  width: "fit-content",
+  display: "grid",
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  ["&[data-isdismissed=true]"]: {
+    display: "none",
+  },
+});
+
+const StyledStackItem = styled("div", {
+  // resets for the Tooltip
+  "*": {
+    width: "100%",
+    height: "100%",
+    padding: 0,
+    margin: 0,
+  },
+
+  img: {
+    borderRadius: gr(2),
+    objectFit: "cover",
+  },
+});
+
+const StyledStackFillerItem = styled(StyledStackItem, {
+  borderBottom: "1px solid $black50",
+  borderRight: "1px solid $black50",
+  borderRadius: `0 0 ${gr(2)}px 0`,
+});
+
+const StyledStackContent = styled(StyledStack, {
+  display: "grid",
+  placeContent: "center",
+
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  ["& > :first-child"]: {
+    // this is the first child of the stack
+    // ensures the Tooltip is hoverable
+    zIndex: 1,
+  },
+
+  [`& ${StyledStackItem}`]: {
+    width: "2rem",
+    height: "2rem",
+    boxShadow: "2px 2px 5px -1px #0002",
+  },
+
+  [`${StyledStackItem}:nth-child(1 of ${StyledStackFillerItem})`]: {
+    transform: "translateX(3px) translateY(3px)",
+  },
+
+  [`${StyledStackItem}:nth-child(2 of ${StyledStackFillerItem})`]: {
+    transform: "translateX(6px) translateY(6px)",
+  },
+});
+
+const StyledStackDismiss = styled("button", {
+  borderRadius: "100%",
+  borderWidth: "1px",
+  borderStyle: "solid",
+  borderColor: "$black50",
+  padding: "calc($gr1 / 4)",
+  display: "flex",
+  backgroundColor: "$gray6",
+  cursor: "pointer",
+  width: "$gr3",
+  height: "$gr3",
+  transform: "translateX(calc(100% + 10px)) translateY(calc(100% + 10px))",
+  transition: "all 200ms ease-in-out",
+  fill: "$black50",
+  // ensure dismiss is above the first child of the stack
+  zIndex: 2,
+
+  svg: {
+    width: "100%",
+    height: "100%",
+    fill: "inherit",
+    stroke: "inherit",
+  },
+
+  [`&:hover`]: {
+    borderColor: "$brightRed",
+    fill: "$white",
+    backgroundColor: "$brightRed",
+    boxShadow: "3px 3px 8px #0002",
+  },
+});
+
+export {
+  StyledStack,
+  StyledStackContent,
+  StyledStackItem,
+  StyledStackFillerItem,
+  StyledStackDismiss,
+};
diff --git a/components/Chat/Stack/Stack.test.tsx b/components/Chat/Stack/Stack.test.tsx
new file mode 100644
index 00000000..833e530f
--- /dev/null
+++ b/components/Chat/Stack/Stack.test.tsx
@@ -0,0 +1,60 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import Stack from "./Stack";
+
+import { sampleWork1 } from "@/mocks/sample-work1";
+import { sampleWork2 } from "@/mocks/sample-work2";
+
+describe("Stack", () => {
+  it("renders stack with no dismiss button", () => {
+    const mockContext = {
+      query: "Dogs",
+      facets: { subject: ["Spaniel"] },
+      works: [sampleWork1, sampleWork2],
+    };
+    render(<Stack context={mockContext} isDismissable={false} />);
+
+    expect(screen.queryByTestId("stack-dismiss")).not.toBeInTheDocument();
+  });
+
+  it("dismisses the stack when dismiss button is clicked", () => {
+    const mockContext = {
+      query: "Dogs",
+      facets: { subject: ["Spaniel"] },
+      works: [sampleWork1, sampleWork2],
+    };
+    render(<Stack context={mockContext} isDismissable />);
+
+    const stack = screen.getByTestId("stack");
+    expect(stack).toHaveAttribute("data-isdismissed", "false");
+
+    const dismissButton = screen.getByTestId("stack-dismiss");
+    expect(dismissButton).toBeInTheDocument();
+
+    fireEvent.click(dismissButton);
+
+    expect(stack).toHaveAttribute("data-isdismissed", "true");
+  });
+
+  it("calls the dismissCallback when dismiss button is clicked", () => {
+    const mockDismissCallback = jest.fn();
+    const mockContext = {
+      query: "Dogs",
+      facets: { subject: ["Spaniel"] },
+      works: [sampleWork1, sampleWork2],
+    };
+
+    render(
+      <Stack
+        context={mockContext}
+        isDismissable
+        dismissCallback={mockDismissCallback}
+      />,
+    );
+
+    const dismissButton = screen.getByTestId("stack-dismiss");
+    fireEvent.click(dismissButton);
+
+    expect(mockDismissCallback).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/components/Chat/Stack/Stack.tsx b/components/Chat/Stack/Stack.tsx
new file mode 100644
index 00000000..8c797666
--- /dev/null
+++ b/components/Chat/Stack/Stack.tsx
@@ -0,0 +1,104 @@
+import type { Work } from "@nulib/dcapi-types";
+import { useState } from "react";
+import * as Tooltip from "@radix-ui/react-tooltip";
+import { TooltipArrow, TooltipBody } from "../../Shared/Tooltip.styled";
+import {
+  TooltipContent,
+  TooltipTrigger,
+} from "@/components/Search/GenerativeAI.styled";
+import {
+  StyledStack,
+  StyledStackContent,
+  StyledStackItem,
+  StyledStackFillerItem,
+  StyledStackDismiss,
+} from "./Stack.styled";
+import { IconClear } from "@/components/Shared/SVG/Icons";
+import { ChatContext } from "@/types/context/search-context";
+
+interface StackProps {
+  context: ChatContext;
+  isDismissable: boolean;
+  dismissCallback?: () => void;
+}
+
+interface SanitizedWork extends Work {
+  title: NonNullable<Work["title"]>;
+  thumbnail: NonNullable<Work["thumbnail"]>;
+}
+
+function isSanitizedWork(work: Partial<Work>): work is SanitizedWork {
+  return work.title && work.thumbnail ? true : false;
+}
+
+/**
+ * Renders a stack of documents
+ */
+const Stack = ({
+  context,
+  isDismissable = true,
+  dismissCallback,
+}: StackProps) => {
+  const [isDismissed, setIsDismissed] = useState(false);
+
+  function handleDismiss() {
+    setIsDismissed(true);
+    if (dismissCallback) {
+      dismissCallback();
+    }
+  }
+
+  const resultsMssg = context
+    ? `Results for '${context.query}' ${Object.keys(context.facets).length ? "filtered on " : ""}${Object.entries(
+        context.facets,
+      )
+        .map(
+          ([key, values]) =>
+            `${key} value(s) ${values?.map((v) => `'${v}'`).join(", ")}`,
+        )
+        .join("; and ")}`
+    : "Filtered search results";
+  return (
+    <StyledStack data-testid="stack" data-isdismissed={isDismissed}>
+      <StyledStackContent>
+        {context.works
+          .filter(isSanitizedWork)
+          .slice(0, 1)
+          .map((document) => (
+            <Tooltip.Provider delayDuration={20} key={document.id}>
+              <Tooltip.Root data-testid="stack-tooltip">
+                <TooltipTrigger asChild>
+                  <StyledStackItem>
+                    <img src={document.thumbnail} alt={document.title} />
+                  </StyledStackItem>
+                </TooltipTrigger>
+                <Tooltip.Portal>
+                  <TooltipContent
+                    side="bottom"
+                    sideOffset={3}
+                    collisionPadding={19}
+                  >
+                    <TooltipArrow />
+                    <TooltipBody>{resultsMssg}</TooltipBody>
+                  </TooltipContent>
+                </Tooltip.Portal>
+              </Tooltip.Root>
+            </Tooltip.Provider>
+          ))}
+        <StyledStackFillerItem></StyledStackFillerItem>
+        <StyledStackFillerItem></StyledStackFillerItem>
+      </StyledStackContent>
+      {isDismissable && (
+        <StyledStackDismiss
+          onClick={() => handleDismiss()}
+          aria-label="Remove results"
+          data-testid="stack-dismiss"
+        >
+          <IconClear title="Remove results" />
+        </StyledStackDismiss>
+      )}
+    </StyledStack>
+  );
+};
+
+export default Stack;
diff --git a/components/Search/Panel.styled.tsx b/components/Search/Panel.styled.tsx
index 53840ff4..e39d76eb 100644
--- a/components/Search/Panel.styled.tsx
+++ b/components/Search/Panel.styled.tsx
@@ -4,6 +4,7 @@ import { styled } from "@/stitches.config";
 
 const SearchResultsLabel = styled(StyledInterstitial, {
   marginBottom: "$gr4",
+  alignItems: "baseline",
   textAlign: "center",
   justifyContent: "space-between",
   width: "100%",
@@ -13,11 +14,16 @@ const SearchResultsLabel = styled(StyledInterstitial, {
     alignItems: "center",
     gap: "$gr2",
   },
+
+  "@sm": {
+    flexDirection: "column",
+    alignItems: "center",
+  },
 });
 
 const StyledBackButton = styled("button", {
   display: "inline-flex",
-  padding: "0 $gr2",
+  padding: "0",
   height: "38px",
   alignItems: "center",
   gap: "$gr1",
@@ -83,9 +89,23 @@ const StyledSearchPanelContent = styled("div", {
   background: "white",
 });
 
+const StyledIncludeResults = styled("div", {
+  display: "flex",
+  alignItems: "center",
+  fontSize: "$gr2",
+  color: "$black50",
+  cursor: "pointer",
+  paddingInlineStart: "calc(1rem + $gr1)",
+
+  input: {
+    margin: "0",
+  },
+});
+
 export {
   StyledSearchPanel,
   StyledSearchPanelContent,
   SearchResultsLabel,
   StyledBackButton,
+  StyledIncludeResults,
 };
diff --git a/components/Search/Panel.tsx b/components/Search/Panel.tsx
index 850fe76f..58a23b25 100644
--- a/components/Search/Panel.tsx
+++ b/components/Search/Panel.tsx
@@ -3,12 +3,14 @@ import {
   CheckboxIndicator,
   CheckboxRoot as CheckboxRootStyled,
 } from "@/components/Shared/Checkbox.styled";
+import type { CheckboxProps } from "@radix-ui/react-checkbox";
 import { IconCheck } from "@/components/Shared/SVG/Icons";
 import {
   SearchResultsLabel,
   StyledBackButton,
   StyledSearchPanel,
   StyledSearchPanelContent,
+  StyledIncludeResults,
 } from "./Panel.styled";
 import { useEffect, useState, useRef } from "react";
 
@@ -27,6 +29,7 @@ import { buildQuery } from "@/lib/queries/builder";
 import { parseUrlFacets } from "@/lib/utils/facet-helpers";
 import { useRouter } from "next/router";
 import { useSearchState } from "@/context/search-context";
+import Stack from "../Chat/Stack/Stack";
 
 const defaultSearchResultsState: SearchResultsState = {
   data: null,
@@ -35,12 +38,13 @@ const defaultSearchResultsState: SearchResultsState = {
 };
 
 const SearchPanel = () => {
+  const [useDocsAsContext, setUseDocsAsContext] = useState(false);
+  const didUrlFacetsChange = useRef(false);
   const router = useRouter();
   const { searchState, searchDispatch } = useSearchState();
-  const [useFacetedDocs, setUseFacetedDocs] = useState(true);
 
   const {
-    panel: { open, query, interstitial },
+    panel: { open, query },
     conversation,
   } = searchState;
 
@@ -105,6 +109,44 @@ const SearchPanel = () => {
     })();
   }, [open, query, page, JSON.stringify(urlFacets)]);
 
+  useEffect(() => {
+    // whenever there are search results, add them to the conversation context
+    searchDispatch({
+      type: "updateConversation",
+      conversation: {
+        ...conversation,
+        context: {
+          // @ts-ignore - data is a Partial<Work>[], but works expects a Work[]
+          works: searchResults.data?.data.slice(0, 20),
+          query: query || "",
+          facets: urlFacets,
+        },
+      },
+    });
+  }, [searchResults]);
+
+  useEffect(() => {
+    // if this is the first time the url facets are being set, set the useDocsAsContext to true
+    // causing the checkbox to be checked
+    if (!didUrlFacetsChange.current && Boolean(Object.keys(urlFacets).length)) {
+      didUrlFacetsChange.current = true;
+      setUseDocsAsContext(true);
+      return;
+    }
+
+    // if the url facets are empty and the checkbox is checked, set the useDocsAsContext to false
+    // causing the checkbox to be unchecked
+    if (didUrlFacetsChange.current && !Boolean(Object.keys(urlFacets).length)) {
+      didUrlFacetsChange.current = false;
+      setUseDocsAsContext(false);
+      return;
+    }
+  }, [urlFacets]);
+
+  const handleCheckChange = (e: CheckboxProps["checked"]) => {
+    setUseDocsAsContext(e?.valueOf() ? true : false);
+  };
+
   const handleEscape = (e: KeyboardEvent) => {
     if (e.key === "Escape") handleBack();
   };
@@ -119,26 +161,23 @@ const SearchPanel = () => {
       })
       .then(() => (isNavigatingBack.current = false));
 
-    const docs = searchResults.data?.data;
-    if (useFacetedDocs && docs) {
-      searchDispatch({
-        type: "updateConversation",
-        conversation: {
-          ...conversation,
-          //@ts-ignore - docs is a Partial<Work>[], but latestDocs expects a Work[]
-          latestDocs: docs.slice(0, 20),
-        },
-      });
-    }
-
     searchDispatch({
       type: "updatePanel",
       panel: {
         open: false,
         query: undefined,
-        interstitial: useFacetedDocs ? "latestdocs" : interstitial,
       },
     });
+
+    if (!useDocsAsContext) {
+      searchDispatch({
+        type: "updateConversation",
+        conversation: {
+          ...conversation,
+          context: undefined,
+        },
+      });
+    }
   };
 
   return (
@@ -158,32 +197,38 @@ const SearchPanel = () => {
             {query && (
               <SearchResultsLabel>
                 <div>
-                  <StyledBackButton onClick={handleBack}>
-                    <IconArrowBack /> Back to conversation
-                  </StyledBackButton>
-                  {Boolean(Object.keys(urlFacets).length) && (
-                    <div style={{ display: "flex" }}>
+                  <div
+                    style={{
+                      display: "flex",
+                      flexDirection: "column",
+                    }}
+                  >
+                    <StyledBackButton onClick={handleBack}>
+                      <IconArrowBack /> Back to conversation
+                    </StyledBackButton>
+                    <StyledIncludeResults>
                       <CheckboxRootStyled
-                        checked={useFacetedDocs}
-                        id="useFacetedDocs"
-                        onCheckedChange={() =>
-                          setUseFacetedDocs(!useFacetedDocs)
-                        }
+                        id="useDocsAsContext"
+                        checked={useDocsAsContext}
+                        onCheckedChange={(e) => handleCheckChange(e)}
                       >
                         <CheckboxIndicator>
                           <IconCheck />
                         </CheckboxIndicator>
                       </CheckboxRootStyled>
-                      <label
-                        htmlFor="use-faceted-docs"
-                        data-selected={useFacetedDocs}
-                      >
-                        Add results to conversation
+                      <label htmlFor="useDocsAsContext">
+                        Chat about results
                       </label>
-                    </div>
-                  )}
+                    </StyledIncludeResults>
+                  </div>
+                  {conversation.context?.works &&
+                    conversation.context.works.length > 0 && (
+                      <Stack
+                        context={conversation.context}
+                        isDismissable={false}
+                      />
+                    )}
                 </div>
-
                 <div>
                   <StyledInterstitialIcon>
                     <IconSparkles />
diff --git a/components/Search/Search.styled.ts b/components/Search/Search.styled.ts
index d9f1466a..325192e9 100644
--- a/components/Search/Search.styled.ts
+++ b/components/Search/Search.styled.ts
@@ -146,9 +146,19 @@ const StyledResponseWrapper = styled("div", {
 
 const StyledTabsContent = styled(TabsContent, {
   display: "grid",
+  gridTemplateColumns: "minmax(0, 1fr) min(1120px, 100%) minmax(0, 1fr)",
+
+  // The overflow-x ensures that when the conversation panel is longer than the SearchPanel,
+  // the conversation panel will not be longer than the SearchPanel.
   overflowX: "hidden",
   overflowY: "clip",
-  gridTemplateColumns: "minmax(0, 1fr) min(1120px, 100%) minmax(0, 1fr)",
+
+  // The overflow-x inadvertently casuses the span in the UserFacets which displays the number of selected facets
+  // to be cut off.
+  // Setting the padding at the top and then a negative margin ensures the number is visible,
+  // while also keep visual consistency with the non-AI search results
+  paddingBlockStart: "$gr3",
+  marginBlockStart: "-$gr3",
 
   ">*:first-child": {
     gridColumn: "2 / 3",
diff --git a/components/Shared/SVG/Icons.tsx b/components/Shared/SVG/Icons.tsx
index 896e2f51..4202842c 100644
--- a/components/Shared/SVG/Icons.tsx
+++ b/components/Shared/SVG/Icons.tsx
@@ -72,9 +72,9 @@ const IconChevronDown: React.FC = () => (
   </svg>
 );
 
-const IconClear: React.FC = () => (
+const IconClear: React.FC<{ title?: string }> = ({ title = "Clear" }) => (
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-    <title>Clear</title>
+    <title>{title}</title>
     <path d="M289.94 256l95-95A24 24 0 00351 127l-95 95-95-95a24 24 0 00-34 34l95 95-95 95a24 24 0 1034 34l95-95 95 95a24 24 0 0034-34z" />
   </svg>
 );
diff --git a/components/Shared/Tooltip.styled.tsx b/components/Shared/Tooltip.styled.tsx
index a25f7a38..c69cefdb 100644
--- a/components/Shared/Tooltip.styled.tsx
+++ b/components/Shared/Tooltip.styled.tsx
@@ -11,7 +11,7 @@ export const TooltipArrow = styled(Tooltip.Arrow, {
 export const TooltipBody = styled("div", {
   background: "$white",
   boxShadow: "5px 5px 19px 0 #0002",
-  width: "450px",
+  maxWidth: "450px",
   lineHeight: "1.5em",
   fontSize: "$gr2 !important",
   fontFamily: "$northwesternSansRegular",
diff --git a/pages/search.tsx b/pages/search.tsx
index 7a8abe58..adbff924 100644
--- a/pages/search.tsx
+++ b/pages/search.tsx
@@ -241,7 +241,7 @@ const SearchPage: NextPage = () => {
               />
             )}
 
-            <StyledTabsContent value="stream">
+            <StyledTabsContent value="stream" id="foo">
               <div
                 style={{
                   transition: "all 382ms ease-in-out",
diff --git a/types/context/search-context.ts b/types/context/search-context.ts
index 80be681f..ed7b32f1 100644
--- a/types/context/search-context.ts
+++ b/types/context/search-context.ts
@@ -8,11 +8,17 @@ export interface Article {
   answer: string;
 }
 
+export interface ChatContext {
+  works: Work[];
+  query: string;
+  facets: UserFacets;
+}
+
 export interface Turn extends Article {
   aggregations: Omit<AggregationResultMessage, "type">["message"][];
   works: Work[][];
   /** Docs users can send along in addition to a question */
-  userDocs?: Work[];
+  context?: ChatContext;
   renderedContent?: React.JSX.Element;
 }
 
@@ -21,8 +27,7 @@ export interface SearchContextStore {
     ref?: string;
     /** the question that kickstarts a conversation */
     initialQuestion: string;
-    /** Docs user adds to context, but not asked a question about */
-    latestDocs?: Work[];
+    context?: ChatContext;
     turns: Turn[];
   };
   panel: {


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a reusable component (Stack) for displaying user documents and removes the old InterstitialDocuments component. Key changes include:
- Integrating the new Stack component into SearchPanel, ChatResponse, and ChatConversation.
- Removing the deprecated InterstitialDocuments component.
- Adjusting styling to support the new Stack component.

### Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| components/Search/Panel.tsx | Updated logic and replaced removed docs control with Stack component usage. |
| components/Chat/Stack/Stack.tsx | Added new Stack component with type-check improvements and rendering of document thumbnails. |
| components/Chat/Stack/Stack.styled.tsx | New styling for Stack component including dismiss button and stack item selectors. |
| components/Chat/Response/Response.tsx | Replaced usage of removed InterstitialDocuments with the Stack component. |
| components/Chat/Conversation.tsx | Integrated dismissal handling and Stack rendering for user docs. |
| components/Chat/Conversation.styled.tsx | Updated layout to accommodate absolute positioning of the Stack component. |
| components/Chat/Chat.tsx | Removed old InterstitialDocuments references. |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-03-26T20:20:16Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a reusable component to display document-related context in the chat conversation by replacing the old "userDocs" approach with a new "ChatContext" and Stack component.  
- Introduces a new ChatContext interface in the types.  
- Updates multiple chat and search components to use the new context instead of the deprecated userDocs.  
- Adds a new Stack component with associated styling for rendering document thumbnails.

### Reviewed Changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                        | Description                                                       |
|---------------------------------------------|-------------------------------------------------------------------|
| types/context/search-context.ts             | Adds ChatContext interface and updates Turn to use context.       |
| components/Shared/SVG/Icons.tsx               | Updates IconClear to support a customizable title for accessibility. |
| components/Search/Panel.tsx                   | Replaces faceted docs logic with context-based document handling and adds a useEffect block. |
| components/Chat/Stack/Stack.tsx               | Introduces the reusable Stack component for displaying document thumbnails. |
| components/Chat/Response/Response.tsx         | Replaces the removed InterstitialDocuments with usage of Stack.   |
| components/Chat/Response/Interstitial.tsx     | Adjusts interstitial handling for the "retrieve_documents" tool.    |
| components/Chat/Conversation.tsx              | Integrates the Stack component and updates conversation context management. |
| components/Chat/Conversation.styled.tsx       | Adjusts layout and positioning to accommodate the new Stack component. |
| components/Chat/Chat.tsx                      | Updates chat turn creation to use the new context field instead of userDocs. |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**types/context/search-context.ts:20**
* [nitpick] The comment above the replaced 'userDocs' field no longer accurately describes the new 'context' field; please update it to reflect that it now holds a ChatContext.
```
/** Docs users can send along in addition to a question */
```
**components/Search/Panel.tsx:103**
* The useEffect hook dispatches conversation updates based on 'urlFacets', 'query', and 'conversation' but only lists 'searchResults' as a dependency; consider adding all relevant dependencies to avoid potential stale values.
```
useEffect(() => {
```
**components/Chat/Conversation.styled.tsx:132**
* [nitpick] The magic number '40px' is used for textIndent; consider extracting this value into a design token or variable for improved maintainability.
```
textIndent: "40px",
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-03-27T19:05:06Z
user: login: mathewjordan
body: Let's roll it in. 🔥 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T18:57:06Z

Review comments:
diff_hunk: @@ -0,0 +1,76 @@
+import type { Work } from "@nulib/dcapi-types";
+
+import { useState } from "react";
+import {
+  StyledStack,
+  StyledStackContent,
+  StyledStackItem,
+  StyledStackFillerItem,
+  StyledStackDismiss,
+} from "./Stack.styled";
+import { IconClear } from "@/components/Shared/SVG/Icons";
+import { useSearchState } from "@/context/search-context";
+
+interface StackProps {
+  documents: Partial<Work>[];
+  isDismissable: boolean;
+  dismissCallback?: () => void;
+}
+
+interface SanitzedWork extends Work {
path: components/Chat/Stack/Stack.tsx
user: login: Copilot
body: The interface name 'SanitzedWork' appears to be misspelled; consider renaming it to 'SanitizedWork'.
```suggestion
interface SanitizedWork extends Work {
```
created_at: 2025-03-26T20:20:15Z
author_association: NONE
diff_hunk: @@ -0,0 +1,74 @@
+import { styled } from "@/stitches.config";
+
+const StyledStack = styled("div", {
+  width: "fit-content",
+  display: "grid",
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  ["&[data-isdismissed=true]"]: {
+    display: "none",
+  },
+});
+
+const StyledStackItem = styled("div");
+
+const StyledStackFillerItem = styled(StyledStackItem, {
+  borderBottom: "1px solid black",
+  borderRight: "1px solid black",
+});
+
+const StyledStackContent = styled(StyledStack, {
+  display: "grid",
+  placeContent: "center",
+
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  [`& ${StyledStackItem}`]: {
+    width: "2rem",
+    height: "2rem",
+
+    img: {
+      width: "100%",
+      height: "100%",
+      objectFit: "cover",
+    },
+  },
+
+  [`${StyledStackItem}:nth-child(1 of ${StyledStackFillerItem})`]: {
+    transform: "translateX(2px) translateY(2px)",
+  },
+
+  [`${StyledStackItem}:nth-child(2 of ${StyledStackFillerItem})`]: {
+    transform: "translateX(5px) translateY(5px)",
+  },
+});
+
+const StyledStackDismiss = styled("button", {
+  borderRadius: "100%",
+  padding: 0,
+  display: "flex",
+  backgroundColor: "$black10",
+  cursor: "pointer",
+  width: "$gr3",
+  height: "$gr3",
+  transform: "translateX(calc(100% + 10px)) translateY(calc(100% + 10px))",
+
+  svg: {
+    widht: "100%",
path: components/Chat/Stack/Stack.styled.tsx
user: login: Copilot
body: The CSS property 'widht' is misspelled; please correct it to 'width'.
```suggestion
    width: "100%",
```
created_at: 2025-03-26T20:20:15Z
author_association: NONE
diff_hunk: @@ -0,0 +1,74 @@
+import { styled } from "@/stitches.config";
+
+const StyledStack = styled("div", {
+  width: "fit-content",
+  display: "grid",
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  ["&[data-isdismissed=true]"]: {
+    display: "none",
+  },
+});
+
+const StyledStackItem = styled("div");
+
+const StyledStackFillerItem = styled(StyledStackItem, {
+  borderBottom: "1px solid black",
+  borderRight: "1px solid black",
+});
+
+const StyledStackContent = styled(StyledStack, {
+  display: "grid",
+  placeContent: "center",
+
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  [`& ${StyledStackItem}`]: {
+    width: "2rem",
+    height: "2rem",
+
+    img: {
+      width: "100%",
+      height: "100%",
+      objectFit: "cover",
+    },
+  },
+
+  [`${StyledStackItem}:nth-child(1 of ${StyledStackFillerItem})`]: {
path: components/Chat/Stack/Stack.styled.tsx
user: login: Copilot
body: The selector syntax 'nth-child(1 of ...)' is unconventional and may not work as intended; consider using a standard selector like ':nth-of-type(1)' if appropriate.
```suggestion
  [`${StyledStackItem}:nth-of-type(1)`]: {
```
created_at: 2025-03-26T20:20:16Z
author_association: NONE
diff_hunk: @@ -0,0 +1,74 @@
+import { styled } from "@/stitches.config";
+
+const StyledStack = styled("div", {
+  width: "fit-content",
+  display: "grid",
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  ["&[data-isdismissed=true]"]: {
+    display: "none",
+  },
+});
+
+const StyledStackItem = styled("div");
+
+const StyledStackFillerItem = styled(StyledStackItem, {
+  borderBottom: "1px solid black",
+  borderRight: "1px solid black",
+});
+
+const StyledStackContent = styled(StyledStack, {
+  display: "grid",
+  placeContent: "center",
+
+  "> *": {
+    gridArea: "1 / 1",
+  },
+
+  [`& ${StyledStackItem}`]: {
+    width: "2rem",
+    height: "2rem",
+
+    img: {
+      width: "100%",
+      height: "100%",
+      objectFit: "cover",
+    },
+  },
+
+  [`${StyledStackItem}:nth-child(1 of ${StyledStackFillerItem})`]: {
+    transform: "translateX(2px) translateY(2px)",
+  },
+
+  [`${StyledStackItem}:nth-child(2 of ${StyledStackFillerItem})`]: {
path: components/Chat/Stack/Stack.styled.tsx
user: login: Copilot
body: The selector syntax 'nth-child(2 of ...)' is non-standard; if the intent is to target specific filler items, consider revising it to a supported pseudo-class such as ':nth-of-type(2)'.
```suggestion
  [`${StyledStackItem}:nth-of-type(2)`]: {
```
created_at: 2025-03-26T20:20:16Z
author_association: NONE

PR comments:
user: login: mathewjordan
created_at: 2025-03-28T17:42:54Z
body: I think it looks really great.

The one minor thing that has me is the Dismiss button.

Can we treat it closer to the Grey/Red "X' clears like other dismissables/close buttons?

![image](https://github.com/user-attachments/assets/dc1041c8-c157-48ee-9723-85fcea6b3a71)

![image](https://github.com/user-attachments/assets/6ae9125b-7834-4f0e-a057-c49d12809171)

author_association: MEMBER
user: login: charlesLoder
created_at: 2025-03-28T18:34:08Z
body: @mathewjordan 
See update:
- there's no inset box shadow because there's too much visually happening with that component already
- updated the colors for consistency with the other components
- The padding on this one is different so that "X" icon is visible (with `padding: "$gr1"` it was just too small)
author_association: NONE