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
15 changes: 4 additions & 11 deletions apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,12 +1533,9 @@ export async function POST(
partialReasoningLength: partialReasoningContent.length,
})

// save partial message
if (
currentThreadId &&
owningThread &&
abortedAssistantContent.length > 0
) {
// save partial message (always non-empty: `buildAbortedAssistantContent`
// closes every aborted turn with the `chat-stopped` marker part)
if (currentThreadId && owningThread) {
try {
const metadata = {
chatMode: selectedMode,
Expand Down Expand Up @@ -1597,11 +1594,7 @@ export async function POST(
error,
})
}
} else if (
currentThreadId &&
!owningThread &&
abortedAssistantContent.length > 0
) {
} else if (currentThreadId && !owningThread) {
console.warn(
'Skipping assistant message save: thread ownership mismatch',
{
Expand Down
24 changes: 24 additions & 0 deletions apps/chat/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,27 @@
.scrollbar-none::-webkit-scrollbar {
display: none;
}

/* Reduced-motion guard (CC-11). tw-animate-css (imported above) generates its
`animate-in`/`animate-out` enter/exit utilities, plus the many named
keyframe modifiers (`fade-in`, `zoom-in-95`, `slide-in-from-*`, ...), as an
open-ended set of dynamically composed classes — there is no fixed list of
selectors to target individually, and several `transition-*` utilities
across the app (hover/focus states, the composer send/cancel crossfade,
etc.) also lack a per-usage `motion-reduce:` variant. A single broad `*`
guard is the conventional, documented way to cover both without auditing
every className site by hand. `animation-duration`/`transition-duration`
collapse to near-zero (not exactly 0) and `animation-iteration-count: 1` so
animation/transition end events still fire — code that waits on them (e.g.
an exit animation before unmount) keeps working, it just resolves
immediately. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
12 changes: 11 additions & 1 deletion apps/chat/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import 'katex/dist/katex.min.css'
import type { Metadata, Viewport } from 'next'
import { hasLocale } from 'next-intl'
import { setRequestLocale } from 'next-intl/server'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { cookies } from 'next/headers'
import { RootIntlProvider } from './RootIntlProvider'
import './globals.css'
Expand Down Expand Up @@ -44,6 +44,7 @@ export default async function RootLayout({
// Same static map as `types/i18n.ts` — the dynamic bare-package-subpath
// import it replaces does not resolve under Turbopack.
const messages = messagesByLocale[locale]
const t = await getTranslations()

return (
<html lang={locale}>
Expand All @@ -59,6 +60,15 @@ export default async function RootLayout({
<body
className={`${sourceSansPro.variable} ${monoSpaceFont.variable} font-sans antialiased`}
>
{/* First focusable element on every page: jumps keyboard/screen-reader
users past the sidebar/header chrome straight to the `<main
id="main-content">` each rendered chat state provides. */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-base focus:font-semibold focus:text-white focus:shadow-lg"
>
{t('chat.a11y.skipToContent')}
</a>
<RootIntlProvider locale={locale} messages={messages}>
{children}
</RootIntlProvider>
Expand Down
6 changes: 4 additions & 2 deletions apps/chat/src/app/noLogin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export default async function Page({ searchParams }: NoLoginPageProps) {
: `${loginBaseUrl}/login`

return (
<div
<main
id="main-content"
tabIndex={-1}
data-cy="chat-no-login"
className="bg-muted flex min-h-screen w-full items-center justify-center px-4"
>
Expand Down Expand Up @@ -69,6 +71,6 @@ export default async function Page({ searchParams }: NoLoginPageProps) {
{t('chat.noLogin.loginButton')}
</Link>
</div>
</div>
</main>
)
}
19 changes: 11 additions & 8 deletions apps/chat/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
unoptimized
className="h-6 w-auto object-contain md:h-8"
/>
{/* The link leaves the app in a new tab; nothing but the
accessible name can carry that hint here. */}
<span className="sr-only">
{t('chat.common.opensInNewTab')}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* V4/D6: the standalone chat view no longer renders the shared
<Footer> band; its legal line moves here instead (embedded mode
never showed the footer or this sidebar, so nothing changes for
it). Wording matches packages/shared-components/src/Footer.tsx,
which is hardcoded English there too — kept as-is rather than
introducing a new i18n key for reused copy. */}
{/* Legal line of the standalone chat view, which renders no footer
band of its own; embedded mode shows neither this sidebar nor a
footer. */}
<p className="text-muted-foreground px-3 pb-2 text-center text-xs">
&copy;{new Date().getFullYear()} DF Teaching Center, Department of
Finance, University of Zurich. All rights reserved.
{t('chat.sidebar.copyright', {
year: String(new Date().getFullYear()),
})}
</p>
</SidebarFooter>
</Sidebar>
Expand Down
47 changes: 25 additions & 22 deletions apps/chat/src/components/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,9 @@ function ParticipationRequired({
: 'https://pwa.klicker.uzh.ch'

return (
<div
<main
id="main-content"
tabIndex={-1}
data-cy="chat-participation-required"
className={twMerge(
'bg-muted flex w-full items-center justify-center px-4',
Expand Down Expand Up @@ -325,24 +327,25 @@ function ParticipationRequired({
</Link>
)}
</div>
</div>
</main>
)
}

function ChatLoading({ embedded }: { readonly embedded: boolean }) {
const t = useTranslations()

return (
<output
<main
id="main-content"
tabIndex={-1}
data-cy="chat-loading"
aria-live="polite"
aria-busy="true"
className={twMerge(
'bg-muted flex items-center justify-center px-4',
embedded ? 'h-full' : 'min-h-screen'
)}
>
<span
<output
aria-live="polite"
className={twMerge(
'bg-card flex w-full items-center gap-4 rounded-xl border p-6 shadow-sm',
embedded ? 'max-w-xs p-4' : 'max-w-sm'
Expand All @@ -369,8 +372,8 @@ function ChatLoading({ embedded }: { readonly embedded: boolean }) {
{t('chat.assistant.loading')}
</span>
</span>
</span>
</output>
</output>
</main>
)
}

Expand All @@ -387,7 +390,9 @@ function DisclaimerDeclined({

return (
<>
<div
<main
id="main-content"
tabIndex={-1}
data-cy="chat-disclaimer-declined"
className={twMerge(
'flex items-center justify-center',
Expand All @@ -412,23 +417,17 @@ function DisclaimerDeclined({
{t('chat.assistant.disclaimerDeclinedMessage')}
</p>
{!embedded && (
// text-white, not text-destructive-foreground: this app's
// theme only defines --color-destructive (see globals.css),
// no matching foreground token. White sits ~4.8:1 on the solid
// destructive bg, near the 4.5:1 AA floor — so the hover must
// darken (brightness-90), not alpha-lighten like the app's
// hover:bg-primary/90 pattern, which would drop below AA here.
<button
type="button"
data-cy="chat-show-disclaimer-again"
onClick={onShowDisclaimer}
className="bg-destructive mt-4 min-h-11 rounded px-4 py-2 text-white transition-[filter] touch-manipulation hover:brightness-90 fine-pointer:min-h-8"
className="bg-primary hover:bg-primary/90 focus-visible:outline-primary/40 mt-4 min-h-11 touch-manipulation rounded-md px-4 py-2 text-base font-semibold text-white transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 fine-pointer:min-h-8"
>
{t('chat.assistant.showDisclaimerAgain')}
</button>
)}
</div>
</div>
</main>

{!embedded && disclaimer && (
<DisclaimerModal
Expand Down Expand Up @@ -501,8 +500,8 @@ function SidebarMain({
}

return (
<SidebarInset>
<div className="bg-muted/50 flex shrink-0 items-center gap-2 border-b px-2 py-1.5">
<SidebarInset id="main-content" tabIndex={-1}>
<header className="bg-muted/50 flex shrink-0 items-center gap-2 border-b px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
{/* Only visible when the sidebar is closed — once it's open, the
sidebar's own trigger closes it, so this stays the single
Expand Down Expand Up @@ -550,7 +549,7 @@ function SidebarMain({
</TooltipTrigger>
<TooltipContent>{t('chat.sidebar.newChat')}</TooltipContent>
</Tooltip>
</div>
</header>
<MobileCreditsBar />
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
Expand Down Expand Up @@ -604,7 +603,11 @@ function AssistantLayout({
</h1>
<EmbeddedSettings />
</div>
<div className="flex min-h-0 flex-1 flex-col">
<main
id="main-content"
tabIndex={-1}
className="flex min-h-0 flex-1 flex-col"
>
<div className="relative flex min-h-0 flex-1 flex-col">
{isLoading && (
<div className="bg-background absolute inset-0 z-10 overflow-y-auto">
Expand All @@ -619,7 +622,7 @@ function AssistantLayout({
/>
</div>
<EmbeddedCreditsBar />
</div>
</main>
</div>
)
}
2 changes: 2 additions & 0 deletions apps/chat/src/components/chat-recovery-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function ChatRecoveryCard({
return (
<main
data-cy={dataCy}
id="main-content"
tabIndex={-1}
className="bg-muted flex min-h-screen w-full items-center justify-center px-4 py-8"
>
<section
Expand Down
35 changes: 34 additions & 1 deletion apps/chat/src/components/chat-ui-context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
'use client'

import { createContext, useContext, useMemo, type ReactNode } from 'react'
import {
createContext,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from 'react'
import { useEmbedded } from '../hooks/useEmbedded'

interface ChatUiContextValue {
Expand Down Expand Up @@ -37,3 +43,30 @@ export function useChatUi() {
}
return context
}

// The disclaimer `Modal` (`@uzh-bf/design-system`) renders as a sibling of
// `ChatUiProvider` in `assistant.tsx`, not inside it, so its open state
// cannot reach the composer through `ChatUiContext` as an ordinary prop.
// This small external store lets `disclaimer-modal.tsx` publish the gate's
// open state and `thread.tsx` subscribe to it across that tree boundary.
let disclaimerGateOpen = false
const disclaimerGateListeners = new Set<() => void>()

export function setDisclaimerGateOpen(open: boolean) {
if (disclaimerGateOpen === open) return
disclaimerGateOpen = open
disclaimerGateListeners.forEach((listener) => listener())
}

function subscribeToDisclaimerGate(listener: () => void) {
disclaimerGateListeners.add(listener)
return () => disclaimerGateListeners.delete(listener)
}

export function useDisclaimerGateOpen() {
return useSyncExternalStore(
subscribeToDisclaimerGate,
() => disclaimerGateOpen,
() => false
)
}
8 changes: 5 additions & 3 deletions apps/chat/src/components/citation-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ export function CitationChip({ index }: { index: number }) {

const handleClick = (event: MouseEvent) => {
event.preventDefault()
document
.getElementById(`src-${messageId}-${source.index}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
const target = document.getElementById(`src-${messageId}-${source.index}`)
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
// `preventScroll` avoids fighting the smooth scroll above: `.focus()`
// would otherwise jump the card into view instantly on its own.
target?.focus({ preventScroll: true })
}

return (
Expand Down
21 changes: 20 additions & 1 deletion apps/chat/src/components/disclaimer-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { Markdown } from '@klicker-uzh/markdown'
import { Button, Modal } from '@uzh-bf/design-system'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { setDisclaimerGateOpen } from './chat-ui-context'

interface ChatbotDisclaimer {
id: string
Expand Down Expand Up @@ -31,6 +32,23 @@ export const DisclaimerModal = ({
}: DisclaimerModalProps) => {
const t = useTranslations()
const [isLoading, setIsLoading] = useState(false)
const acceptButtonRef = useRef<HTMLButtonElement>(null)

// Published to the composer via `chat-ui-context` (see comment there) so
// it can suppress its own autofocus and hand focus back once the gate
// closes, and reset if this component unmounts while still gating.
useEffect(() => {
setDisclaimerGateOpen(isOpen)
return () => setDisclaimerGateOpen(false)
}, [isOpen])

// The design-system `Modal` (@uzh-bf/design-system Modal.tsx) hardcodes
// `onOpenAutoFocus={(e) => e.preventDefault()}` with no prop to override
// it, so Radix never moves focus into the dialog on its own — do it here
// instead, once the Accept button is actually in the DOM.
useEffect(() => {
if (isOpen) acceptButtonRef.current?.focus()
}, [isOpen])

const handleAccept = async () => {
setIsLoading(true)
Expand Down Expand Up @@ -170,6 +188,7 @@ export const DisclaimerModal = ({
{t('chat.disclaimer.decline')}
</Button>
<Button
ref={acceptButtonRef}
primary
data-cy="chat-disclaimer-accept"
onClick={handleAccept}
Expand Down
1 change: 1 addition & 0 deletions apps/chat/src/components/embedded-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function EmbeddedSettings() {
<select
value={selectedMode}
onChange={(e) => setSelectedMode(e.target.value)}
aria-label={t('chat.modes.switcherLabel')}
className="border-input bg-background text-foreground hover:border-ring focus-visible:ring-ring w-full cursor-pointer appearance-none truncate rounded-md border py-1 pl-2 pr-6 text-xs outline-none transition-colors focus-visible:ring-1"
>
{/* Same localized-label source as mode-switcher.tsx (`chat.modes.*`
Expand Down
Loading
Loading