diff --git a/Dockerfile b/Dockerfile
index 65461aa..d8bea2c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,6 +27,7 @@ RUN npm run build
# BACKEND BUILD STAGE
# ================================
FROM public.ecr.aws/docker/library/eclipse-temurin:25-jdk AS builder
+ARG SOURCE_COMMIT=unknown
WORKDIR /app
# 1. Gradle wrapper (rarely changes)
@@ -51,13 +52,14 @@ COPY --from=frontend-builder /app/src/main/resources/static ./src/main/resources
# 6. Build application with cache mount
RUN --mount=type=cache,target=/root/.gradle \
- ./gradlew clean build -x test --no-daemon && \
+ SOURCE_COMMIT="${SOURCE_COMMIT}" ./gradlew clean build -x test --no-daemon && \
cp $(ls build/libs/*.jar | grep -v '\-plain\.jar' | head -n 1) build/app.jar
# ================================
# RUNTIME STAGE
# ================================
FROM public.ecr.aws/docker/library/eclipse-temurin:25-jre AS runtime
+ARG SOURCE_COMMIT=unknown
# 1. System packages (never changes) - FIRST for maximum cache reuse
RUN apt-get update && apt-get install -y --no-install-recommends curl \
@@ -79,6 +81,7 @@ ENV APP_KILL_ON_CONFLICT=false
ENV DOCS_SNAPSHOT_DIR=/app/data/snapshots
ENV DOCS_PARSED_DIR=/app/data/parsed
ENV DOCS_INDEX_DIR=/app/data/index
+ENV SOURCE_COMMIT=${SOURCE_COMMIT}
# 5. Application JAR (changes every build) - LAST for optimal caching
COPY --from=builder /app/build/app.jar app.jar
diff --git a/build.gradle.kts b/build.gradle.kts
index 637dd72..8d7da62 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,9 +12,17 @@ plugins {
val spotbugsToolVersion = "4.9.8"
val pmdToolVersion = "7.20.0"
val palantirVersion = "2.85.0"
+val sourceCommitEnvironmentVariable = "SOURCE_COMMIT"
+val missingSourceCommit = "unknown"
+val sourceCommit = providers.environmentVariable(sourceCommitEnvironmentVariable).orElse(missingSourceCommit)
springBoot {
mainClass.set("com.williamcallahan.javachat.JavaChatApplication")
+ buildInfo {
+ properties {
+ additional.put("commit", sourceCommit)
+ }
+ }
}
group = "com.williamcallahan"
diff --git a/frontend/src/lib/components/CitationPanel.svelte b/frontend/src/lib/components/CitationPanel.svelte
index 3363508..f894ab9 100644
--- a/frontend/src/lib/components/CitationPanel.svelte
+++ b/frontend/src/lib/components/CitationPanel.svelte
@@ -19,6 +19,16 @@
let { citations, visible = true, panelId }: Props = $props()
let isExpanded = $state(false)
+ let citationListElement = $state Implementations must call their provider-specific request path directly instead
+ * of delegating to {@link #embed(List)}. The RAG pipeline logging aspect advises
+ * public {@code embed} executions, so routing scheduled probes around that method
+ * keeps "STEP 1" pipeline logs scoped to real requests.
+
{#each uniqueCitations as citation (citation.url)}
{@const citationType = getCitationType(citation.url)}
{@const displaySource = getDisplaySource(citation.url)}
@@ -201,6 +211,9 @@
flex-direction: column;
gap: var(--citation-list-gap);
animation: slide-down var(--citation-transition-normal) ease-out;
+ /* Clearance for scrollIntoView: must exceed the slide-down translateY(-4px)
+ start offset, since the scroll happens while the entry animation runs. */
+ scroll-margin-bottom: var(--space-3, 12px);
}
@keyframes slide-down {
diff --git a/frontend/src/lib/components/CitationPanel.test.ts b/frontend/src/lib/components/CitationPanel.test.ts
new file mode 100644
index 0000000..c6e3bda
--- /dev/null
+++ b/frontend/src/lib/components/CitationPanel.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, fireEvent } from "@testing-library/svelte";
+import { tick } from "svelte";
+import CitationPanel from "./CitationPanel.svelte";
+import type { Citation } from "../services/chat";
+
+const SINGLE_CITATION: Citation[] = [
+ {
+ url: "https://example.com/pdfs/think-java.pdf",
+ title: "Think Java: How to Think Like a Computer Scientist",
+ snippet: "Think Java 2nd Edition Book",
+ },
+];
+
+describe("CitationPanel", () => {
+ // The prototype polyfill lives in src/test/setup.ts; spying here captures calls
+ // from the panel revealing the expanded list inside its scroll container.
+ const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
+
+ beforeEach(() => {
+ scrollIntoViewSpy.mockClear();
+ });
+
+ it("expands the citation list when the trigger is clicked", async () => {
+ const { getByRole, container } = render(CitationPanel, {
+ props: { citations: SINGLE_CITATION },
+ });
+
+ const trigger = getByRole("button", { name: /1 source/i });
+ expect(container.querySelector(".citation-list")).toBeNull();
+
+ await fireEvent.click(trigger);
+ await tick();
+
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
+ expect(container.querySelector(".citation-list")).not.toBeNull();
+ });
+
+ it("scrolls the expanded list into view so it is never hidden below the fold", async () => {
+ const { getByRole } = render(CitationPanel, {
+ props: { citations: SINGLE_CITATION },
+ });
+
+ await fireEvent.click(getByRole("button", { name: /1 source/i }));
+ await tick();
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith(expect.objectContaining({ block: "nearest" }));
+ });
+
+ it("does not scroll when collapsing the list", async () => {
+ const { getByRole } = render(CitationPanel, {
+ props: { citations: SINGLE_CITATION },
+ });
+
+ const trigger = getByRole("button", { name: /1 source/i });
+ await fireEvent.click(trigger);
+ await tick();
+ scrollIntoViewSpy.mockClear();
+
+ await fireEvent.click(trigger);
+ await tick();
+
+ expect(scrollIntoViewSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte
index 30af6b2..dcdb5b6 100644
--- a/frontend/src/lib/components/Header.svelte
+++ b/frontend/src/lib/components/Header.svelte
@@ -24,6 +24,7 @@
role="tab"
class="nav-tab"
class:active={currentView === 'chat'}
+ aria-label="Chat"
aria-selected={currentView === 'chat'}
onclick={() => currentView = 'chat'}
>
@@ -38,6 +39,7 @@
role="tab"
class="nav-tab"
class:active={currentView === 'learn'}
+ aria-label="Learn"
aria-selected={currentView === 'learn'}
onclick={() => currentView = 'learn'}
>
diff --git a/frontend/src/lib/components/Header.test.ts b/frontend/src/lib/components/Header.test.ts
new file mode 100644
index 0000000..a644f3a
--- /dev/null
+++ b/frontend/src/lib/components/Header.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/svelte";
+import Header from "./Header.svelte";
+
+describe("Header navigation accessibility", () => {
+ it("names icon-only mobile navigation tabs", () => {
+ const { getByRole } = render(Header, {
+ props: { currentView: "chat" },
+ });
+
+ expect(getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "true");
+ expect(getByRole("tab", { name: "Learn" })).toHaveAttribute("aria-selected", "false");
+ });
+});
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 4d9051e..9bb6061 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -22,6 +22,13 @@ Object.defineProperty(HTMLElement.prototype, "scrollTo", {
value: () => {},
});
+// jsdom doesn't implement scrollIntoView; CitationPanel uses it to reveal the expanded list.
+// oxlint-disable-next-line no-extend-native -- jsdom polyfill, not production code
+Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ writable: true,
+ value: () => {},
+});
+
// requestAnimationFrame is used for post-update DOM adjustments; provide a safe fallback.
if (typeof window.requestAnimationFrame !== "function") {
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
diff --git a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java
index 626819a..7ced0e8 100644
--- a/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java
+++ b/src/main/java/com/williamcallahan/javachat/service/EmbeddingClient.java
@@ -7,6 +7,8 @@
* Defines the application's embedding port independent from Spring AI abstractions.
*/
public interface EmbeddingClient {
+ /** Minimal provider input used by keep-alive probes. */
+ String EMBEDDING_WARM_UP_PROBE_TEXT = "embedding model warm-up probe";
/**
* Produces one dense embedding vector per input text, preserving input order.
@@ -37,4 +39,16 @@ default float[] embed(String text) {
* @return embedding vector dimensions
*/
int dimensions();
+
+ /**
+ * Issues a minimal embedding request so the provider keeps its model resident.
+ *
+ *