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
7 changes: 7 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]

[target.x86_64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
# BEGIN Mesh-LLM lld config
[target.aarch64-apple-darwin]
Comment thread
michaelneale marked this conversation as resolved.
Resolved
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/bin/ld64.lld"]

[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/bin/ld64.lld"]
# END Mesh-LLM lld config
4 changes: 3 additions & 1 deletion crates/mesh-llm-ui/e2e/smoke/chat-mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ test.describe('mobile chat composer viewport', () => {
const composerBox = await composer.boundingBox()
const layoutBox = await page.getByTestId('chat-layout').boundingBox()
const viewport = page.viewportSize()
const composerFontSize = await composer.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))
const composerFontSize = await composer.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize)
)

if (!composerBox || !layoutBox || !viewport) return false

Expand Down
2 changes: 1 addition & 1 deletion crates/mesh-llm-ui/e2e/smoke/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test('app smoke navigation shows Network and Chat tabs', async ({ page }) => {
).toHaveAttribute('aria-current', 'page')

await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Chat', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Chat' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Chat', exact: true })).toBeVisible()
await expect(
page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Chat', exact: true })
).toHaveAttribute('aria-current', 'page')
Expand Down
8 changes: 5 additions & 3 deletions crates/mesh-llm-ui/src/app/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function RootLayout({ data = SHELL_HARNESS }: RootLayoutProps = {}) {
<>
<HeadContent />
<LiveStatusConnector />
<div className="min-h-screen">
<div className="flex h-dvh flex-col overflow-hidden">
<TopNav
enabledTabs={enabledTabs}
tab={activeTab === 'configuration' && !newConfigurationPageEnabled ? null : activeTab}
Expand Down Expand Up @@ -142,8 +142,10 @@ export function RootLayout({ data = SHELL_HARNESS }: RootLayoutProps = {}) {
/>
) : null}
<ChatSessionProvider>
<main className="density-shell mx-auto px-[var(--shell-pad-x)] pb-[var(--shell-pad-bottom)] pt-[var(--shell-pad-top)]">
<Outlet />
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="density-shell mx-auto flex min-h-full flex-col px-[var(--shell-pad-x)] pb-[var(--shell-pad-bottom)] pt-[var(--shell-pad-top)]">
<Outlet />
</div>
</main>
</ChatSessionProvider>
<Footer
Expand Down
8 changes: 5 additions & 3 deletions crates/mesh-llm-ui/src/components/ui/InfoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type InfoBannerProps = {
contentClassName?: string
descriptionClassName?: string
leadingIcon?: ReactNode
leadingIconClassName?: string
status?: ReactNode
titleId?: string
titleLevel?: 'h1' | 'h2' | 'h3'
Expand All @@ -25,6 +26,7 @@ export function InfoBanner({
contentClassName,
descriptionClassName,
leadingIcon,
leadingIconClassName,
status,
titleId,
titleLevel = 'h2'
Expand All @@ -35,15 +37,15 @@ export function InfoBanner({
<section
aria-labelledby={titleId}
className={cn(
'panel-shell flex items-center gap-4 rounded-[var(--radius-lg)] border border-border px-[19px] py-[15px]',
'panel-shell flex items-center gap-5 rounded-[var(--radius-lg)] border border-border px-5 py-4',
className
)}
style={{
background:
'linear-gradient(90deg, color-mix(in oklab, var(--color-accent) 10%, var(--color-panel)) 0%, var(--color-panel) 60%)'
}}
>
{leadingIcon ? <AccentIconFrame>{leadingIcon}</AccentIconFrame> : null}
{leadingIcon ? <AccentIconFrame className={leadingIconClassName}>{leadingIcon}</AccentIconFrame> : null}
<div className={cn('min-w-0 flex-1', contentClassName)}>
<div className="flex flex-wrap items-center gap-2">
<Heading
Expand All @@ -58,7 +60,7 @@ export function InfoBanner({
</Heading>
{status ? <div>{status}</div> : null}
</div>
<div className={cn('type-caption mt-1 text-fg-dim', descriptionClassName)}>{description}</div>
<div className={cn('type-caption mt-1.5 text-fg-dim', descriptionClassName)}>{description}</div>
</div>
{action ? (
<div className={cn('flex shrink-0 items-center justify-end self-center', actionClassName)}>{action}</div>
Expand Down
4 changes: 2 additions & 2 deletions crates/mesh-llm-ui/src/components/ui/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ export function StatusBadge({ children, className, dot = false, size = 'label',
return (
<span
className={cn(
'inline-flex items-center gap-[5px] rounded-full px-2 py-px font-medium',
'inline-flex items-center gap-[5px] rounded-full px-2.5 py-0.5 font-medium',
sizeClass[size],
className
)}
style={statusBadgeStyle(tone)}
>
{dot ? <span className="size-[5px] rounded-full bg-current" /> : null}
{dot ? <span className="size-[6px] rounded-full bg-current" /> : null}
{children}
</span>
)
Expand Down
13 changes: 10 additions & 3 deletions crates/mesh-llm-ui/src/components/ui/TabPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type TabPanelProps<TValue extends string = string> = {
iconClassName?: string
iconStrokeWidth?: number
listClassName?: string
stretchTabs?: boolean
tabBarAccessory?: ReactNode
tabBarClassName?: string
tabs: readonly TabPanelItem<TValue>[]
Expand Down Expand Up @@ -65,6 +66,7 @@ export function TabPanel<TValue extends string = string>({
iconClassName,
iconStrokeWidth = 1.6,
listClassName,
stretchTabs = false,
tabBarAccessory,
tabBarClassName,
tabs,
Expand Down Expand Up @@ -104,7 +106,11 @@ export function TabPanel<TValue extends string = string>({
<Tabs.List
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn('flex h-[56px] items-center gap-0 overflow-x-auto overflow-y-hidden', listClassName)}
className={cn(
'flex h-[56px] items-center gap-0 overflow-x-auto overflow-y-hidden',
stretchTabs && 'w-full',
listClassName
)}
>
{tabs.map((item) => {
const active = currentValue === item.value
Expand All @@ -119,7 +125,8 @@ export function TabPanel<TValue extends string = string>({
const trigger = (
<Tabs.Trigger
className={cn(
'-mb-px inline-flex h-[44px] items-center gap-[7px] whitespace-nowrap border-b-2 px-[14px] text-[12.5px] font-medium leading-none tracking-[-0.05px] outline-none transition-[border-color,color] hover:text-fg-dim focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50',
'-mb-px inline-flex h-[44px] items-center gap-2 whitespace-nowrap border-b-2 px-[14px] text-[length:var(--density-type-control)] font-medium leading-none tracking-[-0.01em] outline-none transition-[border-color,color] hover:text-fg-dim focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50',
stretchTabs && 'flex-1 justify-center',
triggerClassName,
item.triggerClassName
)}
Expand All @@ -136,7 +143,7 @@ export function TabPanel<TValue extends string = string>({
) : Icon ? (
<Icon
aria-hidden={true}
className={cn('size-[13px] shrink-0', iconClassName, item.iconClassName)}
className={cn('size-[15px] shrink-0', iconClassName, item.iconClassName)}
strokeWidth={iconStrokeWidth}
/>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function AppHeader({

return (
<header className="shrink-0 border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80">
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-2 px-3 md:h-16 md:gap-4 md:px-4">
<div className="mx-auto flex h-14 w-full max-w-screen-2xl items-center gap-2 px-3 md:h-16 md:gap-4 md:px-4">
<div className="flex min-w-0 items-center gap-0">
<div className="flex h-10 w-7 shrink-0 items-center justify-start">
<BrandIcon className="h-6 w-6 text-primary" />
Expand Down Expand Up @@ -457,7 +457,7 @@ export function AppHeader({
</div>
</div>
{statusError ? (
<div className="mx-auto w-full max-w-7xl px-4 pb-3">
<div className="mx-auto w-full max-w-screen-2xl px-4 pb-3">
<Alert variant="destructive">
<Loader2 className="h-4 w-4 animate-spin" />
<AlertTitle>Connection Interrupted</AlertTitle>
Expand Down
18 changes: 12 additions & 6 deletions crates/mesh-llm-ui/src/features/chat/api/mesh-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ function nowMs() {
return performance.now()
}

function resolveModel(model: string | (() => string)): string {
return typeof model === 'function' ? model() : model
type StringSource = string | (() => string) | { readonly value: string }

function resolveModel(model: StringSource): string {
if (typeof model === 'function') return model()
if (typeof model === 'string') return model
return model.value
}

function resolveSystemPrompt(systemPrompt: string | (() => string) | undefined): string {
function resolveSystemPrompt(systemPrompt: StringSource | undefined): string {
if (!systemPrompt) return ''
return typeof systemPrompt === 'function' ? systemPrompt() : systemPrompt
if (typeof systemPrompt === 'function') return systemPrompt()
if (typeof systemPrompt === 'string') return systemPrompt
return systemPrompt.value
}

function parseChatSSEEvent(data: string) {
Expand Down Expand Up @@ -243,9 +249,9 @@ async function* runConnect(
}

export function createMeshConnectionAdapter(
model: string | (() => string),
model: StringSource,
onResponseMetadata?: (metadata: ChatResponseMetadata) => void,
systemPrompt?: string | (() => string)
systemPrompt?: StringSource
): ConnectConnectionAdapter {
return {
connect: (_messages, _data, abortSignal) =>
Expand Down
81 changes: 68 additions & 13 deletions crates/mesh-llm-ui/src/features/chat/api/use-chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,32 @@ function createUserMessage(content: string): UIMessage {
}

async function drainStream(adapter: ConnectConnectionAdapter, message: UIMessage) {
for await (const _chunk of adapter.connect([message], undefined, undefined)) {
for await (const chunk of adapter.connect([message], undefined, undefined)) {
void chunk
// Drain the stream so the request body is built and posted.
}
}

vi.mock('@tanstack/ai-react', () => ({
useChat: vi.fn(({ connection, initialMessages }: UseChatOptions) => ({
messages: initialMessages,
sendMessage: vi.fn((content: string) => drainStream(connection, createUserMessage(content))),
setMessages: vi.fn(),
reload: vi.fn(),
stop: vi.fn(),
status: 'ready',
error: null,
isLoading: false
}))
}))
vi.mock('@tanstack/ai-react', async () => {
const React = await import('react')

return {
useChat: vi.fn(({ connection, initialMessages }: UseChatOptions) => {
const connectionRef = React.useRef(connection)

return {
messages: initialMessages,
sendMessage: vi.fn((content: string) => drainStream(connectionRef.current, createUserMessage(content))),
setMessages: vi.fn(),
reload: vi.fn(),
stop: vi.fn(),
status: 'ready',
error: null,
isLoading: false
}
})
}
})

function createSSEStream(lines: string[]) {
const encoder = new TextEncoder()
Expand Down Expand Up @@ -70,6 +79,31 @@ function SendFirstMessageOnLayout() {
return null
}

function SendMessageOnLayout({
message,
model,
systemPrompt
}: {
message?: string
model: string
systemPrompt: string
}) {
const chat = useMeshChat({
conversationId: 'chat-1',
model,
systemPrompt,
initialMessages: []
})

useLayoutEffect(() => {
if (message) {
void chat.sendMessage(message)
}
}, [chat, message])

return null
}

describe('useMeshChat', () => {
beforeEach(() => {
vi.restoreAllMocks()
Expand All @@ -89,4 +123,25 @@ describe('useMeshChat', () => {
expect(body.input[0]).toEqual({ role: 'system', content: DEFAULT_SYSTEM_PROMPT })
expect(body.input[1]).toEqual({ role: 'user', content: 'What is MeshLLM?' })
})

it('sends the latest model and system prompt after rerendering the chat hook', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(createSSEStream(['data: [DONE]\n']), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)

const { rerender } = render(<SendMessageOnLayout model="model-a" systemPrompt="prompt-a" />)

rerender(<SendMessageOnLayout message="Use latest values" model="model-b" systemPrompt="prompt-b" />)

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))

const request = fetchMock.mock.calls[0]?.[1]
const body = JSON.parse(String(request?.body)) as {
model: string
input: Array<{ role: string; content: string }>
}

expect(body.model).toBe('model-b')
expect(body.input[0]).toEqual({ role: 'system', content: 'prompt-b' })
expect(body.input[1]).toEqual({ role: 'user', content: 'Use latest values' })
})
})
34 changes: 22 additions & 12 deletions crates/mesh-llm-ui/src/features/chat/api/use-chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useChat } from '@tanstack/ai-react'
import type { UseChatReturn } from '@tanstack/ai-react'
import type { ThreadMessage } from '@/features/app-tabs/types'
Expand All @@ -14,6 +14,19 @@ type UseMeshChatOptions = {
onResponseMetadata?: (metadata: ChatResponseMetadata) => void
}

function createMutableStringSource() {
let current = ''

return {
get value() {
return current
},
setValue(value: string) {
current = value
}
}
}

export function useMeshChat({
conversationId,
model,
Expand All @@ -22,20 +35,17 @@ export function useMeshChat({
onResponseMetadata
}: UseMeshChatOptions): UseChatReturn {
const previousConversationIdRef = useRef(conversationId)
const currentModelRef = useRef(model)
const currentSystemPromptRef = useRef(systemPrompt)
const currentModel = useMemo(() => createMutableStringSource(), [])
const currentSystemPrompt = useMemo(() => createMutableStringSource(), [])

currentModelRef.current = model
currentSystemPromptRef.current = systemPrompt
useLayoutEffect(() => {
currentModel.setValue(model)
currentSystemPrompt.setValue(systemPrompt)
}, [currentModel, currentSystemPrompt, model, systemPrompt])

const connection = useMemo(
() =>
createMeshConnectionAdapter(
() => currentModelRef.current,
onResponseMetadata,
() => currentSystemPromptRef.current
),
[onResponseMetadata]
() => createMeshConnectionAdapter(currentModel, onResponseMetadata, currentSystemPrompt),
[currentModel, currentSystemPrompt, onResponseMetadata]
)
const hydratedMessages = useMemo(() => threadMessagesToUIMessages(initialMessages), [initialMessages])
const chat = useChat({ id: conversationId, connection, initialMessages: hydratedMessages })
Expand Down
Loading
Loading