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
5 changes: 3 additions & 2 deletions apps/conciv/src/pane/chat-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {ToolFallbackCard} from './tool-fallback-card.js'
import {useComposerTriggerSources} from './trigger-sources.js'
import {GrabReference} from './grab-reference.js'
import {CompactSpinner, ConversationSkeleton, Divider, ThinkingBubble} from './indicators.js'
import {ComposerActionsPending} from '../shell/pending.js'
import {EmptyStateSlot} from '../shell/empty-state.js'
import {ExtensionSurface} from '../extension/extension-slots.js'
import {makePaneGrabApi} from '../extension/pane-grab.js'
Expand Down Expand Up @@ -313,7 +314,7 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
>
<Thread>
<Thread.Viewport>
<Suspense>
<Suspense fallback={<ConversationSkeleton />}>
<Thread.Welcome>
<Show when={!disconnected()} fallback={<ConversationSkeleton />}>
<EmptyStateSlot
Expand Down Expand Up @@ -383,7 +384,7 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
busy={compacting() ? <CompactSpinner /> : undefined}
triggers={triggerSources}
>
<Suspense>
<Suspense fallback={<ComposerActionsPending />}>
<ComposerActions
sessionId={sessionId}
compacting={compacting()}
Expand Down
63 changes: 37 additions & 26 deletions apps/conciv/src/routes/panel.$sessionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {Outlet, createFileRoute, redirect, useBlocker, useMatchRoute, useRouter}
import {useQuery} from '@tanstack/solid-query'
import {Tabs, TooltipIconButton} from '@conciv/ui-kit-system'
import {ChevronDown, PictureInPicture2, Unplug} from 'lucide-solid'
import {For, Show, createMemo, createSignal, type JSX} from 'solid-js'
import {For, Show, Suspense, createMemo, createSignal, type JSX} from 'solid-js'
import {Dynamic} from 'solid-js/web'
import {isSessionId} from '@conciv/protocol/chat-types'
import {useAnnounce, useAppData, useDisconnect, useGrabProvider, useInstances, useRpc} from '../app/context.js'
import {PaneContext, makeGrabStore, makePendingAttachmentQueue, type PaneContextValue} from '../app/pane-context.js'
import {SessionSelector} from '../composer/session-selector.js'
import {usePanelChrome} from '../app/panel-chrome.js'
import {ContextTracker} from '../pane/context-tracker.js'
import {SessionPillPending, UsagePending, ViewTabsPending} from '../shell/pending.js'
import {collectViews} from '../extension/extension-views.js'

const HEAD = 'flex items-center gap-2.5 py-3 px-3.5 border-b border-b-pw-line-soft'
Expand Down Expand Up @@ -111,13 +112,17 @@ function PanelSession(): JSX.Element {
<PictureInPicture2 class="size-5 block" aria-hidden="true" />
</TooltipIconButton>
<span class="tracking-[-0.01em] font-semibold">conciv</span>
<SessionSelector
variant="pill"
activeId={() => params().sessionId}
onActivate={activate}
onNewSession={() => void newSession()}
/>
<ContextTracker usage={usage()} />
<Suspense fallback={<SessionPillPending variant="pill" />}>
<SessionSelector
variant="pill"
activeId={() => params().sessionId}
onActivate={activate}
onNewSession={() => void newSession()}
/>
</Suspense>
<Suspense fallback={<UsagePending />}>
<ContextTracker usage={usage()} />
</Suspense>
<Show when={connectMode && disconnect}>
<TooltipIconButton
tooltip="Disconnect this machine"
Expand All @@ -136,24 +141,30 @@ function PanelSession(): JSX.Element {
</TooltipIconButton>
</header>
<Show when={views().length > 0}>
<div class="px-2.5 flex gap-2 items-center">
<Tabs.Root value={activeView()} onValueChange={(details) => switchView(details.value)} class="flex-1 min-w-0">
<Tabs.List>
<Tabs.Trigger value="chat" disabled={leaveGuard()}>
Chat
</Tabs.Trigger>
<For each={views()}>
{(view) => (
<Tabs.Trigger value={view.id} disabled={leaveGuard()}>
<Show when={view.icon}>{(icon) => <Dynamic component={icon()} class="size-3.5" />}</Show>
{view.label}
</Tabs.Trigger>
)}
</For>
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
</div>
<Suspense fallback={<ViewTabsPending />}>
<div class="px-2.5 flex gap-2 items-center">
<Tabs.Root
value={activeView()}
onValueChange={(details) => switchView(details.value)}
class="flex-1 min-w-0"
>
<Tabs.List>
<Tabs.Trigger value="chat" disabled={leaveGuard()}>
Chat
</Tabs.Trigger>
<For each={views()}>
{(view) => (
<Tabs.Trigger value={view.id} disabled={leaveGuard()}>
<Show when={view.icon}>{(icon) => <Dynamic component={icon()} class="size-3.5" />}</Show>
{view.label}
</Tabs.Trigger>
)}
</For>
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
</div>
</Suspense>
</Show>
<Outlet />
</PaneContext.Provider>
Expand Down
21 changes: 13 additions & 8 deletions apps/conciv/src/routes/quick.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {createFileRoute, redirect, useRouter} from '@tanstack/solid-router'
import {useQuery} from '@tanstack/solid-query'
import {createHotkey} from '@tanstack/solid-hotkeys'
import {For, Show, onCleanup, onMount, type JSX} from 'solid-js'
import {For, Show, Suspense, onCleanup, onMount, type JSX} from 'solid-js'
import {TooltipIconButton, createResizable} from '@conciv/ui-kit-system'
import {ChevronUp, Columns2, PictureInPicture2, X} from 'lucide-solid'
import {useAppData, useRpc, useSuppressed} from '../app/context.js'
import {PaneProvider} from '../app/pane-provider.js'
import {ChatPane} from '../pane/chat-pane.js'
import {ContextTracker} from '../pane/context-tracker.js'
import {SessionSelector} from '../composer/session-selector.js'
import {SessionPillPending, UsagePending} from '../shell/pending.js'
import {QuickSearchSchema, quickPaneIds, quickSearchFor} from '../lib/quick-search.js'

const CLOSE =
Expand Down Expand Up @@ -191,13 +192,17 @@ function QuickLayer(): JSX.Element {
}}
>
<div class="text-xs text-pw-text-3 leading-none font-pw-mono px-3 py-2 border-b border-b-pw-line-soft flex shrink-0 gap-2 items-center">
<SessionSelector
variant="bar"
activeId={() => id}
onActivate={(next) => activatePane(index(), next)}
onNewSession={() => void addPane()}
/>
<ContextTracker usage={usageOf(id)} />
<Suspense fallback={<SessionPillPending variant="bar" />}>
<SessionSelector
variant="bar"
activeId={() => id}
onActivate={(next) => activatePane(index(), next)}
onNewSession={() => void addPane()}
/>
</Suspense>
<Suspense fallback={<UsagePending />}>
<ContextTracker usage={usageOf(id)} />
</Suspense>
<TooltipIconButton
tooltip="Close pane"
class={CLOSE_PANE}
Expand Down
2 changes: 1 addition & 1 deletion apps/conciv/src/shell/fab-robot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function FabRobot(props: {open: () => boolean; working: () => boolean}) {
onCleanup(() => rig?.destroy())

return (
<span class="pw-fab-rig" data-working={props.working()} aria-hidden="true">
<span class="pw-fab-rig" aria-hidden="true">
<span class="pw-rig-layer pw-rig-head" ref={(el) => (headEl = el)} />
<span class="pw-rig-layer pw-rig-antenna" ref={(el) => (antEl = el)} />
<span class="pw-rig-layer pw-rig-eyes" ref={(el) => (eyesEl = el)} />
Expand Down
17 changes: 11 additions & 6 deletions apps/conciv/src/shell/fab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {JSX} from 'solid-js'
import {Show, Suspense, type JSX} from 'solid-js'
import type {TriggerPosition} from '@conciv/protocol/config-types'
import type {DraggablePosition} from '../lib/draggable-position.js'
import {FabRobot} from './fab-robot.js'
Expand All @@ -14,12 +14,12 @@ const FAB_POS: Record<TriggerPosition, string> = {

const FAB_BASE =
'fixed size-13 rounded-pw-pill border border-pw-line bg-pw-panel text-pw-accent text-[1.375rem] cursor-pointer pointer-events-auto shadow-pw-lg inline-flex items-center justify-center trans-lift anim-fab focus-ring [@media(hover:hover)_and_(pointer:fine)]:hover:[transform:translateY(-0.125rem)] [@media(hover:hover)_and_(pointer:fine)]:hover:shadow-pw-hover active:[transform:translateY(0)_scale(0.94)]'
const FAB_ATTN =
"after:content-[''] after:absolute after:-inset-[0.1875rem] after:rounded-pw-pill after:border-2 after:border-pw-accent after:anim-fab-ring"
const FAB_BUSY = 'pw-fab-busy absolute -inset-[0.1875rem] rounded-pw-pill pointer-events-none'
const FAB_ATTN = 'border-2 border-pw-accent anim-fab-ring'
const FAB_DRAGGING = 'transition-none z-[2147483647] cursor-grabbing'

function fabClass(pulsing: boolean, position: TriggerPosition, dragging: boolean): string {
return `${FAB_BASE} ${FAB_POS[position]}${pulsing ? ` ${FAB_ATTN}` : ''}${dragging ? ` ${FAB_DRAGGING}` : ''}`
function fabClass(position: TriggerPosition, dragging: boolean): string {
return `${FAB_BASE} ${FAB_POS[position]}${dragging ? ` ${FAB_DRAGGING}` : ''}`
}

function fabLabel(open: boolean): string {
Expand All @@ -38,7 +38,7 @@ export function ShellFab(props: {
<button
type="button"
ref={props.ref}
class={fabClass(!props.open() && props.working(), props.fab.position(), props.fab.dragging())}
class={fabClass(props.fab.position(), props.fab.dragging())}
data-pw-fab
data-pw-suppressed={props.suppressed()}
style={props.fab.dragStyle()}
Expand All @@ -50,6 +50,11 @@ export function ShellFab(props: {
if (!props.fab.consumeClick()) props.onToggle()
}}
>
<Suspense>
<Show when={props.working()}>
<span class={props.open() ? FAB_BUSY : `${FAB_BUSY} ${FAB_ATTN}`} aria-hidden="true" />
</Show>
</Suspense>
<FabRobot open={props.open} working={props.working} />
</button>
)
Expand Down
45 changes: 45 additions & 0 deletions apps/conciv/src/shell/pending.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {type JSX} from 'solid-js'

const SKEL = 'skel-bg [background-size:200%_100%] anim-skel'

export function SessionPillPending(props: {variant: 'pill' | 'bar'}): JSX.Element {
return (
<div class="inline-flex min-w-0 items-center" role="status">
<div
class={`${SKEL} h-7 ${props.variant === 'pill' ? 'w-32 rounded-pw-pill' : 'w-24 rounded-pw-sm'}`}
aria-hidden="true"
/>
<span class="sr-only">Loading sessions…</span>
</div>
)
}

export function UsagePending(): JSX.Element {
return (
<div class="inline-flex items-center px-1.5 py-0.5" role="status">
<div class={`${SKEL} h-4 w-10 rounded-pw-sm`} aria-hidden="true" />
<span class="sr-only">Loading context usage…</span>
</div>
)
}

export function ViewTabsPending(): JSX.Element {
return (
<div class="px-2.5 flex gap-2 items-center" role="status">
<div class={`${SKEL} h-8 w-16 rounded-pw-sm`} aria-hidden="true" />
<div class={`${SKEL} h-8 w-20 rounded-pw-sm`} aria-hidden="true" />
<span class="sr-only">Loading views…</span>
</div>
)
}

export function ComposerActionsPending(): JSX.Element {
return (
<div class="flex gap-1 items-center" role="status">
<div class={`${SKEL} size-8.5 rounded-pw-pill`} aria-hidden="true" />
<div class={`${SKEL} size-8.5 rounded-pw-pill`} aria-hidden="true" />
<div class={`${SKEL} size-8.5 rounded-pw-pill`} aria-hidden="true" />
<span class="sr-only">Loading composer actions…</span>
</div>
)
}
2 changes: 1 addition & 1 deletion apps/conciv/src/styles.css

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

42 changes: 42 additions & 0 deletions packages/embed/tests/e2e/panel-focus.it.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import {hostPage, serveHost} from '../helpers/host.js'
const suite = setupWidgetSuite()

const COMPOSER_NAME = 'Message the conciv agent'
const SESSION_PILL_NAME = 'Session: New session'

function composer(page: Page) {
return page.getByRole('textbox', {name: COMPOSER_NAME})
}

function sessionPill(page: Page) {
return page.getByRole('button', {name: SESSION_PILL_NAME})
}

async function ensurePanelClosed(page: Page): Promise<void> {
const minimize = page.getByRole('button', {name: 'Minimize conciv chat'})
const opener = page.getByRole('button', {name: 'Open conciv chat'})
Expand All @@ -19,6 +24,23 @@ async function ensurePanelClosed(page: Page): Promise<void> {
await expect(opener).toBeVisible({timeout: 30_000})
}

async function holdFirstSessionList(page: Page): Promise<() => void> {
let release = (): void => {}
const held = new Promise<void>((resolve) => {
release = () => resolve()
})
let seen = 0
await page.route(
(url) => url.pathname.endsWith('/rpc/sessions/list'),
async (route) => {
seen += 1
if (seen === 1) await held
await route.continue()
},
)
return release
}

type HostedPanel = {host: Awaited<ReturnType<typeof serveHost>>; page: Page; hostButton: Locator}

const dedicatedHosts: Array<{close: () => Promise<void>}> = []
Expand Down Expand Up @@ -55,6 +77,26 @@ test.describe('panel open focuses the composer', () => {
await page.keyboard.type('typed without clicking')
await expect(composer(page)).toHaveText('typed without clicking')
})

test('paints the shell while the session list is still loading, then focuses the composer', async ({page}) => {
test.setTimeout(90_000)
const host = await serveHost(() =>
hostPage({apiBase: suite.kit().base, widget: '{"quickTerminal":false,"transport":"fetch"}'}),
)
dedicatedHosts.push(host)
const releaseSessionList = await holdFirstSessionList(page)
await page.goto(host.base, {waitUntil: 'domcontentloaded'})
try {
await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 15_000})
} finally {
releaseSessionList()
}
await openPanel(page)
await expect(sessionPill(page)).toBeVisible({timeout: 30_000})
await expect(composer(page)).toBeFocused({timeout: 10_000})
await page.keyboard.type('typed after the session list resolved')
await expect(composer(page)).toHaveText('typed after the session list resolved')
})
})

test.describe('panel close restores focus: host element captured at open wins, FAB is the fallback', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {Page} from 'playwright'
import {afterAll, beforeAll} from 'vitest'
import {expect as expectLocator} from 'playwright/test'
import {getExtensionTestApi, serveDir, type ExtensionTestApi} from '@conciv/extension-testkit'
import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer'
import type {FrameworkAdapter} from '@conciv/protocol/framework-types'
import tanstackExtension from '../../src/server.js'

Expand Down Expand Up @@ -38,7 +39,9 @@ export function tanstackAdapter(api: ExtensionTestApi): FrameworkAdapter {
}

export async function waitForWidget(page: Page): Promise<void> {
const planeSubscribed = rpcObserverFor(page).completed({path: ['page', 'queries'], timeout: 30_000})
await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000})
await planeSubscribed
}

export async function gotoAbout(page: Page): Promise<void> {
Expand Down
Loading