From 71e38a2150f457757892beea422fd4bd4a6e3fc7 Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Fri, 25 Sep 2026 12:24:21 -0700 Subject: [PATCH] Refine Explore live experience Add the live Copilot dock, swipeable Discover deck, shared navigation, and viewport-safe Explore layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PRIVACY.md | 9 +- README.md | 13 +- docs/setup.md | 8 +- mobile/src/app/App.tsx | 42 +- mobile/src/app/__tests__/App.test.tsx | 66 +- mobile/src/ui/explore/ExploreScreen.tsx | 666 +++++++++--------- .../explore/__tests__/ExploreScreen.test.tsx | 578 +++++++-------- mobile/src/ui/screens/SessionScreen.tsx | 28 +- mobile/src/ui/styles/explore.css | 464 +++++++++++- .../.openspec.yaml | 2 + .../refine-explore-live-experience/design.md | 109 +++ .../proposal.md | 35 + .../mobile-explore-live-experience/spec.md | 127 ++++ .../refine-explore-live-experience/tasks.md | 43 ++ 14 files changed, 1464 insertions(+), 726 deletions(-) create mode 100644 openspec/changes/refine-explore-live-experience/.openspec.yaml create mode 100644 openspec/changes/refine-explore-live-experience/design.md create mode 100644 openspec/changes/refine-explore-live-experience/proposal.md create mode 100644 openspec/changes/refine-explore-live-experience/specs/mobile-explore-live-experience/spec.md create mode 100644 openspec/changes/refine-explore-live-experience/tasks.md diff --git a/PRIVACY.md b/PRIVACY.md index 9abdadd..e3880c8 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -69,10 +69,11 @@ request metadata such as your IP address and browser headers when those assets l ## Optional Explore video provider -Explore's Agent Pulse, bundled Discover cards, Play activities, Unwind activities, -saved-card state, and scores remain local to the phone. If a deployment configures the -optional Watch widget, Weft displays a disclosure and does not contact that provider -until you explicitly choose to load the video feed. +Explore's live Copilot dock only projects activity already present in the active +end-to-end encrypted session. Its bundled shuffled Discover deck, saved-card state, +Play activities, Unwind activities, and scores remain local to the phone. If a +deployment configures the optional Watch widget, Weft displays a disclosure and does +not contact that provider until you explicitly choose to load the video feed. The provider runs in a sandboxed frame and receives no Weft prompts, transcripts, repository names, filenames, tool arguments, agent activity, channel identifiers, or diff --git a/README.md b/README.md index f40f203..18d4902 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,9 @@ while you use the phone. - **Shared terminal** — open and resume one real shell on supported Windows laptops. See [terminal controls and access boundaries](docs/terminal.md). - **Explore between turns** — open the permanent compass destination for local agent - activity, 50 offline Discover cards, two lightweight games, guided rest activities, - and an optional explicitly loaded third-party short-video widget. + activity streamed into a compact bottom dock, a balanced swipe-through deck of 50 + offline Discover cards, two lightweight games, guided rest activities, and an optional + explicitly loaded third-party short-video widget. ## Trust model @@ -124,9 +125,11 @@ while you use the phone. `~/.weft/`; phone session data remains in local device storage. - **Explicit authority.** The phone receives the same approval boundaries exposed by the underlying Copilot session. Weft does not invent an automatic approval policy. -- **Explore stays local by default.** Agent Pulse, Discover, Play, Unwind, saved cards, - and scores remain on the phone. The optional Watch provider loads only after an explicit - tap and receives no Weft prompt, repository, filename, tool, or session context. +- **Explore stays local by default.** The live Copilot dock projects data already present + in the active encrypted session, while the shuffled Discover deck, saved-card signals, + Play, Unwind, and scores remain on the phone. Attention states return to the canonical + chat controls. The optional Watch provider loads only after an explicit tap and receives + no Weft prompt, repository, filename, tool, or session context. Read the full [security model](docs/security.md), [privacy policy](PRIVACY.md), and [pairing protocol](docs/pairing.md). diff --git a/docs/setup.md b/docs/setup.md index ec73136..05d5d06 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -68,9 +68,11 @@ npm run dev # open the printed localhost URL ### Optional Explore video widget -Explore's Discover, Play, Unwind, and Agent Pulse experiences require no external -content provider. To exercise the Watch category, set an HTTPS URL for an isolated, -auto-updating widget before starting or building the mobile app: +Explore's balanced Discover deck, Play, Unwind, and live Copilot dock require no +external content provider. The dock only projects activity already available in the +active Weft session and returns attention actions to chat. To exercise the Watch +category, set an HTTPS URL for an isolated, auto-updating widget before starting or +building the mobile app: ```powershell $env:VITE_EXPLORE_WIDGET_URL = 'https://your-widget-provider.example/embed/...' diff --git a/mobile/src/app/App.tsx b/mobile/src/app/App.tsx index 912433d..4f0ff78 100644 --- a/mobile/src/app/App.tsx +++ b/mobile/src/app/App.tsx @@ -26,9 +26,6 @@ const SessionScreen = lazy(() => const TerminalScreen = lazy(() => import('@/ui/screens/TerminalScreen').then((module) => ({ default: module.TerminalScreen })), ); -const ExploreScreen = lazy(() => - import('@/ui/explore/ExploreScreen').then((module) => ({ default: module.ExploreScreen })), -); type ModalHistoryState = { weftView: 'devices' } | { weftView: 'device-details' | 'terminal'; channelId: string } | @@ -536,6 +533,11 @@ export default function App(): JSX.Element { setAdding(true); }} onOpenExplore={openExplore} + exploreOpen={exploreOpen} + onCloseExplore={() => { + const state = window.history.state as ModalHistoryState; + window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1); + }} onStartSession={() => openStart()} onOpenDevices={openDevices} devices={snapshot.devices} @@ -554,6 +556,11 @@ export default function App(): JSX.Element { if (operationId) void sessionAccess.retry(operationId); }} onGoHome={() => { + if (exploreOpen) { + const state = window.history.state as ModalHistoryState; + window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1); + setExploreOpen(false); + } setError(null); setShowLanding(true); }} @@ -562,31 +569,8 @@ export default function App(): JSX.Element { ); return ( - <> -
- - {sessionScreen} - -
- {exploreOpen ? ( - - window.history.back()} - onOpenChat={() => window.history.go(-2)} - onApprove={(requestId, optionId) => - void sessionRuntime.sendApproval(active.meta.channelId, requestId, optionId) - } - onElicitationRespond={(requestId, action, content) => - void sessionRuntime.sendElicitation(active.meta.channelId, requestId, action, content) - } - /> - - ) : null} - + + {sessionScreen} + ); } diff --git a/mobile/src/app/__tests__/App.test.tsx b/mobile/src/app/__tests__/App.test.tsx index 97bbd9e..7e0b362 100644 --- a/mobile/src/app/__tests__/App.test.tsx +++ b/mobile/src/app/__tests__/App.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '@/app/App'; const runtime = vi.hoisted(() => { @@ -77,27 +77,51 @@ vi.mock('@/ui/hooks/usePairing', () => ({ isNativeRuntime: () => false, })); +vi.mock('@/ui/screens/LandingScreen', () => ({ + LandingScreen: () =>
Landing
, +})); + vi.mock('@/ui/screens/SessionScreen', () => ({ - SessionScreen: ({ onOpenExplore }: { onOpenExplore(): void }) => { + SessionScreen: ({ + onOpenExplore, + exploreOpen, + onGoHome, + }: { + onOpenExplore(): void; + exploreOpen: boolean; + onGoHome(): void; + }) => { const [count, setCount] = useState(0); return ( -
- Local state {count} - - -
+ <> +
+ Local state {count} + + +
+ {exploreOpen ? ( +
+ Explore overlay + +
+ ) : null} + ); }, })); -vi.mock('@/ui/explore/ExploreScreen', () => ({ - ExploreScreen: () =>
Explore overlay
, -})); - beforeEach(() => { window.history.replaceState(null, ''); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('App Explore layering', () => { it('keeps the active SessionScreen mounted and inert beneath Explore, then restores it on Back', async () => { render(); @@ -108,8 +132,8 @@ describe('App Explore layering', () => { fireEvent.click(screen.getByRole('button', { name: 'Open Explore' })); expect(await screen.findByTestId('explore-screen')).toBeInTheDocument(); expect(session).toBeInTheDocument(); - expect(session.parentElement).toHaveAttribute('aria-hidden', 'true'); - expect(session.parentElement).toHaveAttribute('inert'); + expect(session).toHaveAttribute('aria-hidden', 'true'); + expect(session).toHaveAttribute('inert'); act(() => { window.dispatchEvent(new PopStateEvent('popstate', { state: null })); @@ -117,7 +141,19 @@ describe('App Explore layering', () => { expect(screen.queryByTestId('explore-screen')).not.toBeInTheDocument(); expect(screen.getByText('Local state 1')).toBeInTheDocument(); - expect(session.parentElement).not.toHaveAttribute('aria-hidden'); - expect(session.parentElement).not.toHaveAttribute('inert'); + expect(session).not.toHaveAttribute('aria-hidden'); + expect(session).not.toHaveAttribute('inert'); + }); + + it('collapses both Explore history entries when going Home from a category', async () => { + const go = vi.spyOn(window.history, 'go').mockImplementation(() => {}); + render(); + await screen.findByTestId('session-screen'); + fireEvent.click(screen.getByRole('button', { name: 'Open Explore' })); + window.history.pushState({ weftView: 'explore', exploreView: 'discover' }, ''); + + fireEvent.click(screen.getByRole('button', { name: 'Go Home' })); + + expect(go).toHaveBeenCalledWith(-2); }); }); diff --git a/mobile/src/ui/explore/ExploreScreen.tsx b/mobile/src/ui/explore/ExploreScreen.tsx index f6d942e..7746582 100644 --- a/mobile/src/ui/explore/ExploreScreen.tsx +++ b/mobile/src/ui/explore/ExploreScreen.tsx @@ -11,11 +11,9 @@ import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from 'react'; -import type { ElicitationRequestMsg } from '@aasis21/weft-shared'; import type { SessionView } from '@/session/view'; import type { TimelineItem, ToolItem } from '@/lib/timeline'; import { isWorking } from '@/ui/sessions/sessionStatus'; -import { ElicitationCard } from '@/ui/prompts/ElicitationCard'; import { DISCOVER_CARDS, DISCOVER_TOPIC_LABELS, @@ -24,11 +22,10 @@ import { } from './discoverCards'; export type ExploreCategory = 'discover' | 'watch' | 'play' | 'unwind'; -type ExploreView = ExploreCategory | 'agent' | null; -type FieldValue = string | number | boolean | string[]; +type ExploreView = ExploreCategory | null; type ExploreHistoryState = { weftView: 'explore'; - exploreView?: Exclude; + exploreView?: ExploreCategory; }; function historyExploreView(state: ExploreHistoryState | null): ExploreView { @@ -36,30 +33,26 @@ function historyExploreView(state: ExploreHistoryState | null): ExploreView { return candidate === 'discover' || candidate === 'watch' || candidate === 'play' || - candidate === 'unwind' || - candidate === 'agent' + candidate === 'unwind' ? candidate : null; } interface ExploreScreenProps { active: SessionView; - onBack(): void; + onOpenSessions(): void; onOpenChat(): void; - onApprove(requestId: string, optionId: string): void; - onElicitationRespond( - requestId: string, - action: 'accept' | 'decline' | 'cancel', - content?: Record, - ): void; + onGoHome(): void; + desktopDocked?: boolean; } -export type AgentPulseTone = 'attention' | 'error' | 'working' | 'ready' | 'idle'; +export type LiveDockTone = 'attention' | 'error' | 'working' | 'ready' | 'idle'; -export interface AgentPulseState { - tone: AgentPulseTone; +export interface LiveDockState { + tone: LiveDockTone; label: string; - detail: string; + text: string; + detail: string | null; startedAt: number | null; } @@ -92,6 +85,9 @@ interface ExploreStoredState { lastCategory?: ExploreCategory; savedCardIds?: string[]; completedCardIds?: string[]; + discoverDay?: string; + discoverCycle?: number; + discoverIndex?: number; threadlineCompleted?: number; signalBest?: number; vibration?: boolean; @@ -101,6 +97,9 @@ const DEFAULT_STORED_STATE: Required = { lastCategory: 'discover', savedCardIds: [], completedCardIds: [], + discoverDay: '', + discoverCycle: 0, + discoverIndex: 0, threadlineCompleted: 0, signalBest: 0, vibration: false, @@ -126,6 +125,15 @@ function parseStoredState(): Required { completedCardIds: Array.isArray(parsed.completedCardIds) ? parsed.completedCardIds.filter((value): value is string => typeof value === 'string') : [], + discoverDay: typeof parsed.discoverDay === 'string' ? parsed.discoverDay : '', + discoverCycle: + typeof parsed.discoverCycle === 'number' && parsed.discoverCycle >= 0 + ? Math.floor(parsed.discoverCycle) + : 0, + discoverIndex: + typeof parsed.discoverIndex === 'number' && parsed.discoverIndex >= 0 + ? Math.floor(parsed.discoverIndex) + : 0, threadlineCompleted: typeof parsed.threadlineCompleted === 'number' && parsed.threadlineCompleted >= 0 ? Math.floor(parsed.threadlineCompleted) @@ -163,13 +171,32 @@ function toolLabel(tool: ToolItem): string { return `${tool.name.replace(/[_-]+/g, ' ')} in progress`; } -export function deriveAgentPulse(active: SessionView): AgentPulseState { +function latestAssistant(active: SessionView, onlyStreaming = false) { + return [...active.timeline.items].reverse().find( + (item) => item.kind === 'assistant' && (!onlyStreaming || !item.final) && item.text.trim(), + ); +} + +function latestAssistantText(active: SessionView, onlyStreaming = false): string | null { + const assistant = latestAssistant(active, onlyStreaming); + return assistant?.kind === 'assistant' ? assistant.text : null; +} + +function dockExcerpt(value: string, max = 220): string { + const clean = value.replace(/[`*_>#-]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (clean.length <= max) return clean; + return `…${clean.slice(clean.length - max + 1)}`; +} + +export function deriveLiveDock(active: SessionView, replyCompletedInExplore = false): LiveDockState { if (active.timeline.approvals.length > 0) { const count = active.timeline.approvals.length; + const request = active.timeline.approvals[0]; return { tone: 'attention', label: 'Approval needed', - detail: `${count} action${count === 1 ? '' : 's'} waiting for you`, + text: request.toolName || 'Copilot needs permission to continue', + detail: `${count} action${count === 1 ? '' : 's'} waiting in chat`, startedAt: null, }; } @@ -177,42 +204,58 @@ export function deriveAgentPulse(active: SessionView): AgentPulseState { return { tone: 'attention', label: 'Question waiting', - detail: 'The agent needs your answer to continue', + text: 'Copilot needs your answer to continue', + detail: 'Open chat to respond', startedAt: null, }; } if (active.error) { - return { tone: 'error', label: 'Session disconnected', detail: active.error, startedAt: null }; + return { + tone: 'error', + label: 'Session disconnected', + text: dockExcerpt(active.error), + detail: 'Open chat to reconnect', + startedAt: null, + }; } if (active.status === 'ended') { return { tone: 'error', label: 'Session ended', - detail: active.timeline.endedReason ?? 'Return to chat to reconnect.', + text: active.timeline.endedReason ?? 'Return to chat to reconnect.', + detail: 'Open chat for session options', startedAt: null, }; } if (isWorking(active.timeline)) { const running = latestRunningTool(active); + const streamed = latestAssistantText(active, true); return { tone: 'working', - label: active.intent?.trim() || (running ? toolLabel(running) : 'Agent working'), - detail: running ? `Using ${running.name}` : 'Work is continuing in the active session', + label: streamed ? 'Copilot is writing' : 'Copilot is working', + text: streamed + ? dockExcerpt(streamed) + : active.intent?.trim() || (running ? toolLabel(running) : 'Working in the active session'), + detail: running ? `Using ${running.name.replace(/[_-]+/g, ' ')}` : active.intent?.trim() || null, startedAt: active.thinkingSince ?? running?.startedAt ?? active.timeline.busyFrom, }; } - if (active.unread || (active.unreadCount ?? 0) > 0) { + if (replyCompletedInExplore || active.unread || (active.unreadCount ?? 0) > 0) { + const reply = latestAssistantText(active); return { tone: 'ready', label: 'Reply ready', - detail: 'Return to the conversation when you are ready', + text: reply ? dockExcerpt(reply) : 'Copilot finished responding', + detail: 'Open chat to continue', startedAt: null, }; } + const recent = latestAssistantText(active); return { tone: 'idle', - label: active.status === 'live' ? 'Agent ready' : 'Session quiet', - detail: recentActivityLabel(active.timeline.items), + label: active.status === 'live' ? 'Copilot ready' : 'Session quiet', + text: recent ? dockExcerpt(recent) : recentActivityLabel(active.timeline.items), + detail: null, startedAt: null, }; } @@ -297,22 +340,59 @@ function BookmarkGlyph({ filled }: { filled: boolean }): JSX.Element { export function ExploreScreen({ active, - onBack, + onOpenSessions, onOpenChat, - onApprove, - onElicitationRespond, + onGoHome, + desktopDocked = false, }: ExploreScreenProps): JSX.Element { const [stored, setStored] = useState>(() => parseStoredState()); const [view, setView] = useState(null); const [now, setNow] = useState(Date.now()); - const pulse = deriveAgentPulse(active); - const interrupted = active.timeline.approvals.length > 0 || active.timeline.elicitations.length > 0; + const latestFinal = latestAssistant(active); + const latestFinalId = + latestFinal?.kind === 'assistant' && latestFinal.final ? latestFinal.id : null; + const working = isWorking(active.timeline); + const replyTrackerRef = useRef({ + channelId: active.meta.channelId, + wasWorking: working, + baselineFinalId: latestFinalId, + }); + const [replyReadyId, setReplyReadyId] = useState(null); + useEffect(() => { + const tracker = replyTrackerRef.current; + if (tracker.channelId !== active.meta.channelId) { + replyTrackerRef.current = { + channelId: active.meta.channelId, + wasWorking: working, + baselineFinalId: latestFinalId, + }; + setReplyReadyId(null); + return; + } + if (!tracker.wasWorking && working) { + tracker.baselineFinalId = latestFinalId; + setReplyReadyId(null); + } else if ( + tracker.wasWorking && + !working && + latestFinalId && + latestFinalId !== tracker.baselineFinalId + ) { + setReplyReadyId(latestFinalId); + } + tracker.wasWorking = working; + }, [active.meta.channelId, latestFinalId, working]); + const replyCompletedInExplore = + replyTrackerRef.current.channelId === active.meta.channelId && + !working && + latestFinalId === replyReadyId; + const dock = deriveLiveDock(active, replyCompletedInExplore); useEffect(() => { - if (pulse.tone !== 'working') return undefined; + if (dock.tone !== 'working') return undefined; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); - }, [pulse.tone]); + }, [dock.tone]); useEffect(() => { const onPopState = (event: PopStateEvent): void => { @@ -329,7 +409,7 @@ export function ExploreScreen({ writeStoredState(next); }, []); - const navigate = (next: Exclude): void => { + const navigate = (next: ExploreCategory): void => { const state = { weftView: 'explore', exploreView: next } satisfies ExploreHistoryState; if (view === null) window.history.pushState(state, ''); else window.history.replaceState(state, ''); @@ -341,56 +421,32 @@ export function ExploreScreen({ navigate(category); }; - const handleBack = (): void => { - if (view !== null) { - window.history.back(); - return; - } - onBack(); - }; - return ( -
-
+
+
- + {desktopDocked ? ( + + ) : ( + + )}
- - {view === 'agent' ? 'Agent activity' : view ? CATEGORY_META[view].title : 'Explore'} - {view === null ? 'Something useful in the meantime' : 'Explore without leaving your session'} - + Explore
-
- { - if (pulse.tone === 'attention') return; - navigate('agent'); - }} - /> - {view === null ? ( - - ) : view === 'agent' ? ( - + ) : ( <> @@ -407,30 +463,21 @@ export function ExploreScreen({
)} +
- -
); } function ExploreHome({ - stored, onOpen, }: { - stored: Required; onOpen(category: ExploreCategory): void; }): JSX.Element { return (
-

Your space

What do you feel like?

-

Pick something useful, interesting, playful, or calm. Your Copilot session keeps running.

{(Object.keys(CATEGORY_META) as ExploreCategory[]).map((category) => { @@ -450,13 +497,6 @@ function ExploreHome({ ); })}
-
); } @@ -485,80 +525,86 @@ function CategoryTabs({ ); } -function AgentPulse({ - pulse, +function LiveCopilotDock({ + dock, now, - onClick, + onOpenChat, }: { - pulse: AgentPulseState; + dock: LiveDockState; now: number; - onClick(): void; + onOpenChat(): void; }): JSX.Element { - const elapsed = formatElapsed(pulse.startedAt, now); + const elapsed = formatElapsed(dock.startedAt, now); return ( ); } -function AgentActivity({ active, onOpenChat }: { active: SessionView; onOpenChat(): void }): JSX.Element { - const visibleItems = [...active.timeline.items].reverse().slice(0, 10); - return ( -
-
-

Current session

-

{active.meta.title}

-

{active.intent ?? recentActivityLabel(active.timeline.items)}

-
-
    - {visibleItems.length === 0 ? ( -
  1. No recent activity has arrived yet.
  2. - ) : ( - visibleItems.map((item) => ) - )} -
- -
- ); +function shorten(value: string, max: number): string { + const clean = value.replace(/\s+/g, ' ').trim(); + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; } -function AgentActivityItem({ item }: { item: TimelineItem }): JSX.Element { - if (item.kind === 'tool') { - return ( -
  • - - {item.name}{toolLabel(item)} -
  • - ); - } - if (item.kind === 'assistant') { - return
  • Reply{shorten(item.text, 120)}
  • ; +function discoverDayKey(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function hashSeed(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); } - if (item.kind === 'notice') { - return
  • Notice{item.text}
  • ; + return hash >>> 0; +} + +function shuffleCards(cards: DiscoverCard[], random: () => number): DiscoverCard[] { + const result = [...cards]; + for (let index = result.length - 1; index > 0; index -= 1) { + const target = Math.floor(random() * (index + 1)); + [result[index], result[target]] = [result[target], result[index]]; } - return
  • Your message{shorten(item.text, 120)}
  • ; + return result; } -function shorten(value: string, max: number): string { - const clean = value.replace(/\s+/g, ' ').trim(); - return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +export function buildDiscoverDeck(cards: DiscoverCard[], seed: number): DiscoverCard[] { + const topics = Object.keys(DISCOVER_TOPIC_LABELS) as DiscoverTopic[]; + const random = seededRandom(seed || 1); + const start = seed % topics.length; + const topicOrder = [...topics.slice(start), ...topics.slice(0, start)]; + const buckets = new Map( + topics.map((topic) => [topic, shuffleCards(cards.filter((card) => card.topic === topic), random)]), + ); + const result: DiscoverCard[] = []; + let remaining = cards.length; + while (remaining > 0) { + for (const topic of topicOrder) { + const next = buckets.get(topic)?.shift(); + if (!next) continue; + result.push(next); + remaining -= 1; + } + } + return result; } function DiscoverView({ @@ -568,93 +614,139 @@ function DiscoverView({ stored: Required; onChange(next: Required): void; }): JSX.Element { - const [topic, setTopic] = useState('all'); - const [reader, setReader] = useState(null); - const scrollRef = useRef(null); - const savedScroll = useRef(0); - const saved = useMemo(() => new Set(stored.savedCardIds), [stored.savedCardIds]); - const completed = useMemo(() => new Set(stored.completedCardIds), [stored.completedCardIds]); - const visibleCards = topic === 'all' ? DISCOVER_CARDS : DISCOVER_CARDS.filter((card) => card.topic === topic); - - const toggleSaved = (id: string): void => { + const today = discoverDayKey(); + const cycle = stored.discoverDay === today ? stored.discoverCycle : 0; + const persistedIndex = stored.discoverDay === today ? stored.discoverIndex : 0; + const deck = useMemo( + () => buildDiscoverDeck(DISCOVER_CARDS, hashSeed(`${today}:${cycle}`)), + [cycle, today], + ); + const index = Math.min(persistedIndex, Math.max(0, deck.length - 1)); + const card = deck[index]; + const saved = new Set(stored.savedCardIds); + const pointerStart = useRef<{ x: number; y: number } | null>(null); + const viewRef = useRef(null); + const [scrollable, setScrollable] = useState(false); + + useLayoutEffect(() => { + const element = viewRef.current; + if (!element) return undefined; + const measure = (): void => { + setScrollable(element.scrollHeight > element.clientHeight + 1); + }; + measure(); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); + observer?.observe(element); + window.addEventListener('resize', measure); + return () => { + observer?.disconnect(); + window.removeEventListener('resize', measure); + }; + }, [card.id]); + + useEffect(() => { + if ( + stored.discoverDay !== today || + stored.discoverCycle !== cycle || + stored.discoverIndex !== index + ) { + onChange({ ...stored, discoverDay: today, discoverCycle: cycle, discoverIndex: index }); + } + }, [cycle, index, onChange, stored, today]); + + const changeCard = (direction: 1 | -1): void => { + if (!card) return; + const completed = stored.completedCardIds.includes(card.id) + ? stored.completedCardIds + : [...stored.completedCardIds, card.id]; + if (direction < 0) { + if (index === 0) return; + onChange({ ...stored, completedCardIds: completed, discoverDay: today, discoverCycle: cycle, discoverIndex: index - 1 }); + return; + } + if (index < deck.length - 1) { + onChange({ ...stored, completedCardIds: completed, discoverDay: today, discoverCycle: cycle, discoverIndex: index + 1 }); + return; + } + onChange({ ...stored, completedCardIds: completed, discoverDay: today, discoverCycle: cycle + 1, discoverIndex: 0 }); + }; + + const toggleSaved = (): void => { + if (!card) return; const next = new Set(saved); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(card.id)) next.delete(card.id); + else next.add(card.id); onChange({ ...stored, savedCardIds: [...next] }); }; - const openReader = (card: DiscoverCard): void => { - savedScroll.current = scrollRef.current?.scrollTop ?? 0; - setReader(card); - if (!completed.has(card.id)) { - onChange({ ...stored, completedCardIds: [...stored.completedCardIds, card.id] }); - } + const onPointerUp = (event: ReactPointerEvent): void => { + const start = pointerStart.current; + pointerStart.current = null; + if (!start) return; + const deltaX = event.clientX - start.x; + const deltaY = event.clientY - start.y; + if (Math.abs(deltaY) < 48 || Math.abs(deltaY) <= Math.abs(deltaX) * 1.2) return; + changeCard(deltaY < 0 ? 1 : -1); }; - useLayoutEffect(() => { - if (reader === null && scrollRef.current) scrollRef.current.scrollTop = savedScroll.current; - }, [reader]); - - if (reader) { - return ( -
    - -

    {DISCOVER_TOPIC_LABELS[reader.topic]} · {reader.minutes} min

    -

    {reader.title}

    -

    {reader.summary}

    - -

    The useful idea

    -

    {reader.insight}

    -

    - Notice this idea the next time you encounter it in daily life. A small mental model is most - useful when it helps explain something that previously felt mysterious. -

    - -
    - ); - } + if (!card) return
    No Discover cards are available.
    ; return ( -
    -
    - - {(Object.keys(DISCOVER_TOPIC_LABELS) as DiscoverTopic[]).map((value) => ( - - ))} -
    -
    - {visibleCards.map((card) => ( -
    - - -
    - ))} -
    -
    + {index + 1} / {deck.length} +
    + + +
    + + + ); } @@ -1390,119 +1482,3 @@ function EyeHorizon({ ); } - -function ExploreInterruptions({ - active, - onApprove, - onElicitationRespond, -}: { - active: SessionView; - onApprove(requestId: string, optionId: string): void; - onElicitationRespond( - requestId: string, - action: 'accept' | 'decline' | 'cancel', - content?: Record, - ): void; -}): JSX.Element | null { - const approval = active.timeline.approvals[0]; - const elicitation: ElicitationRequestMsg | undefined = active.timeline.elicitations[0]; - const responsive = active.status === 'live' && !active.error; - const dialogRef = useRef(null); - const previousFocusRef = useRef(null); - const wasOpenRef = useRef(false); - const open = Boolean(approval || elicitation); - const requestId = approval?.requestId ?? elicitation?.requestId; - - useEffect(() => { - if (open && !wasOpenRef.current) { - previousFocusRef.current = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - } - wasOpenRef.current = open; - if (open) { - const firstAction = dialogRef.current?.querySelector( - 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', - ); - (firstAction ?? dialogRef.current)?.focus(); - } else { - const previous = previousFocusRef.current; - previousFocusRef.current = null; - if (previous?.isConnected) previous.focus(); - } - }, [open, requestId]); - - useEffect(() => () => { - const previous = previousFocusRef.current; - if (previous?.isConnected) previous.focus(); - }, []); - - if (!open) return null; - return ( -
    { - if (event.key !== 'Tab') return; - const focusable = [...(dialogRef.current?.querySelectorAll( - 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', - ) ?? [])]; - if (focusable.length === 0) { - event.preventDefault(); - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last?.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first?.focus(); - } - }} - > -
    -
    - {approval ? ( - <> -

    Approval needed

    -

    {approval.toolName}

    -

    The agent is blocked until you choose an action.

    - {active.timeline.approvalErrors[approval.requestId] ? ( -

    {active.timeline.approvalErrors[approval.requestId]}

    - ) : null} -
    - {approval.options.map((option) => ( - - ))} -
    - - ) : elicitation ? ( - onElicitationRespond(elicitation.requestId, 'accept', content)} - onDecline={() => onElicitationRespond(elicitation.requestId, 'decline')} - onCancel={() => onElicitationRespond(elicitation.requestId, 'cancel')} - /> - ) : null} -
    -
    - ); -} diff --git a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx index e98f07e..74b2a2c 100644 --- a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx +++ b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx @@ -5,17 +5,18 @@ import { emptyTimeline } from '@/lib/timeline'; import type { SessionView } from '@/session/view'; import { ExploreScreen, + buildDiscoverDeck, countThreadlineCrossings, createSignalSequence, createThreadlinePoints, - deriveAgentPulse, + deriveLiveDock, } from '@/ui/explore/ExploreScreen'; import { DISCOVER_CARDS } from '@/ui/explore/discoverCards'; function session(overrides: Partial = {}): SessionView { return { meta: { - kind: 'paired', + kind: 'live', channelId: 'channel-1', title: 'Explore session', cwd: 'C:\\repo', @@ -33,10 +34,9 @@ function renderExplore(active = session()) { return render( , ); } @@ -53,382 +53,333 @@ afterEach(() => { }); describe('Explore catalog', () => { - it('ships exactly 50 unique, readable starter cards', () => { + it('ships exactly 50 unique, bounded starter cards', () => { expect(DISCOVER_CARDS).toHaveLength(50); expect(new Set(DISCOVER_CARDS.map((card) => card.id)).size).toBe(50); for (const card of DISCOVER_CARDS) { expect(card.title.length).toBeGreaterThan(8); + expect(card.title.length).toBeLessThanOrEqual(52); expect(card.summary.length).toBeGreaterThan(40); + expect(card.summary.length).toBeLessThanOrEqual(170); expect(card.insight.length).toBeGreaterThan(40); + expect(card.insight.length).toBeLessThanOrEqual(190); expect(card.minutes).toBeGreaterThan(0); } }); }); -describe('Agent Pulse', () => { - it('prioritizes approvals over working state and does not alter the session status', () => { - const timeline = { - ...emptyTimeline(), - busy: true, - busyFrom: 1_000, - approvals: [ - { +describe('Live Copilot Dock', () => { + it('prioritizes attention over working state', () => { + const active = session({ + timeline: { + ...emptyTimeline(), + busy: true, + approvals: [{ requestId: 'approval-1', - toolName: 'powershell', - toolArgs: { command: 'npm test' }, + toolName: 'Run tests', + toolArgs: {}, options: [{ id: 'approve', label: 'Approve' }], - }, - ], - }; - const active = session({ timeline }); + }], + }, + }); - expect(deriveAgentPulse(active)).toMatchObject({ + expect(deriveLiveDock(active)).toMatchObject({ tone: 'attention', label: 'Approval needed', + text: 'Run tests', }); - expect(active.status).toBe('live'); }); - it('uses live agent intent and timing while work is in flight', () => { - const active = session({ - intent: 'running focused mobile tests', - thinkingSince: 5_000, - timeline: { ...emptyTimeline(), busy: true, busyFrom: 4_000 }, - }); - - expect(deriveAgentPulse(active)).toEqual({ - tone: 'working', - label: 'running focused mobile tests', - detail: 'Work is continuing in the active session', - startedAt: 5_000, - }); - }); - - it('opens current activity without replacing the chat status', async () => { - const user = userEvent.setup(); + it('projects streamed assistant text into the fixed dock and opens chat', async () => { const onOpenChat = vi.fn(); const active = session({ - intent: 'Checking the mobile release', - timeline: { ...emptyTimeline(), busy: true, busyFrom: Date.now() - 4_000 }, + timeline: { + ...emptyTimeline(), + busy: true, + busyFrom: Date.now() - 4_000, + items: [{ + kind: 'assistant', + id: 'reply-1', + text: 'I am checking the focused mobile tests now.', + ts: Date.now(), + final: false, + }], + }, }); + const user = userEvent.setup(); render( , ); - await user.click(screen.getByRole('button', { name: /Checking the mobile release/ })); - expect(screen.getByRole('heading', { name: 'Explore session' })).toBeInTheDocument(); - expect(screen.getAllByText('Checking the mobile release')).toHaveLength(2); - await user.click(screen.getByRole('button', { name: 'Return to conversation' })); + const dock = screen.getByRole('button', { name: /Copilot is writing/ }); + expect(dock).toHaveTextContent('I am checking the focused mobile tests now.'); + await user.click(dock); expect(onOpenChat).toHaveBeenCalledOnce(); - expect(active.status).toBe('live'); - }); -}); - -describe('Explore navigation and content', () => { - it('opens with Agent Pulse and four choices, then replaces choices with category tabs', async () => { - const user = userEvent.setup(); - renderExplore(); - - expect(screen.getByRole('button', { name: /Agent ready/ })).toBeInTheDocument(); - const discoverChoice = screen.getByRole('button', { name: 'Discover: Read something worth knowing' }); - expect(discoverChoice).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Watch: Short visual content' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Play: Puzzles and mini-games' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Unwind: Breathe, reset, and rest' })).toBeInTheDocument(); - - await user.click(discoverChoice); - - expect(screen.getByRole('navigation', { name: 'Explore categories' })).toBeInTheDocument(); - expect(screen.queryByText('What do you feel like?')).not.toBeInTheDocument(); - expect(screen.getByText('How QR codes survive scratches')).toBeInTheDocument(); }); - it('integrates category and agent detail state with browser history', () => { - const onBack = vi.fn(); - render( + it('shows a newly completed active-session reply without relying on unread state', () => { + const props = { + onOpenSessions: vi.fn(), + onOpenChat: vi.fn(), + onGoHome: vi.fn(), + }; + const { rerender } = render( , ); - fireEvent.click(screen.getByRole('button', { name: 'Unwind: Breathe, reset, and rest' })); - expect(window.history.state).toEqual({ weftView: 'explore', exploreView: 'unwind' }); - - fireEvent.click(screen.getByRole('button', { name: 'Discover' })); - expect(window.history.state).toEqual({ weftView: 'explore', exploreView: 'discover' }); - - act(() => { - window.dispatchEvent(new PopStateEvent('popstate', { - state: { weftView: 'explore' }, - })); - }); - expect(screen.getByRole('heading', { name: 'What do you feel like?' })).toBeInTheDocument(); - expect(onBack).not.toHaveBeenCalled(); + rerender( + , + ); - fireEvent.click(screen.getByRole('button', { name: 'Back to chat' })); - expect(onBack).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: /Reply ready/ })).toHaveTextContent( + 'The implementation is complete.', + ); }); - it('keeps bundled Discover cards readable while offline', () => { - vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); - renderExplore(); - fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); - expect(screen.getByRole('button', { name: 'Read How QR codes survive scratches' })).toBeInTheDocument(); + it('handles final-before-idle ordering and resets completion tracking across sessions', () => { + const props = { + onOpenSessions: vi.fn(), + onOpenChat: vi.fn(), + onGoHome: vi.fn(), + }; + const streaming = (busy: boolean, final: boolean, channelId = 'channel-1') => session({ + meta: { + kind: 'live', + channelId, + title: 'Explore session', + cwd: 'C:\\repo', + addedAt: 1, + scannedAt: 1, + }, + timeline: { + ...emptyTimeline(), + busy, + items: [{ + kind: 'assistant', + id: channelId === 'channel-1' ? 'ordered-reply' : 'other-reply', + text: final ? 'Ordered reply complete.' : 'Writing ordered reply.', + ts: 100, + final, + }], + }, + }); + const { rerender } = render(); + rerender(); + expect(screen.getByRole('button', { name: /Copilot is working/ })).toBeInTheDocument(); + rerender(); + expect(screen.getByRole('button', { name: /Reply ready/ })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('button', { name: /Copilot ready/ })).toBeInTheDocument(); }); - it('opens a readable card and persists its saved state locally', async () => { - const user = userEvent.setup(); - renderExplore(); - await user.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); - await user.click(screen.getByRole('button', { name: 'Read How QR codes survive scratches' })); + it('falls back through intent, running tool, reply, and idle states', () => { + expect(deriveLiveDock(session({ + intent: 'Running focused checks', + timeline: { ...emptyTimeline(), busy: true, busyFrom: 100 }, + }))).toMatchObject({ tone: 'working', text: 'Running focused checks' }); - expect(screen.getByRole('heading', { name: 'How QR codes survive scratches' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Save for later' })); - expect(screen.getByRole('button', { name: 'Saved' })).toBeInTheDocument(); - expect(localStorage.getItem('weft.explore.v1')).toContain('qr-error-correction'); - }); + expect(deriveLiveDock(session({ + timeline: { + ...emptyTimeline(), + busy: true, + items: [{ + kind: 'tool', + id: 'tool-1', + name: 'powershell', + args: { description: 'Run mobile tests' }, + status: 'running', + startedAt: 100, + ts: 100, + }], + }, + }))).toMatchObject({ tone: 'working', text: 'Run mobile tests' }); - it('keeps approval decisions actionable above Explore content', async () => { - const onApprove = vi.fn(); - const active = session({ + expect(deriveLiveDock(session({ + unread: true, timeline: { ...emptyTimeline(), - approvals: [ - { - requestId: 'approval-1', - toolName: 'Run tests', - toolArgs: { command: 'npm test' }, - options: [ - { id: 'approve', label: 'Approve' }, - { id: 'deny', label: 'Deny' }, - ], - }, - ], + items: [{ + kind: 'assistant', + id: 'reply-1', + text: 'The implementation is ready.', + ts: 100, + final: true, + }], }, - }); + }))).toMatchObject({ tone: 'ready', text: 'The implementation is ready.' }); + + expect(deriveLiveDock(session())).toMatchObject({ tone: 'idle', label: 'Copilot ready' }); + }); +}); + +describe('Explore navigation and Discover deck', () => { + it('uses the shared navigation affordance and compact four-choice home', async () => { + const onOpenSessions = vi.fn(); + const user = userEvent.setup(); render( , ); - expect(screen.getByRole('dialog', { name: 'Agent needs your attention' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Approve' })); - expect(onApprove).toHaveBeenCalledWith('approval-1', 'approve'); + expect(screen.getByRole('heading', { name: 'What do you feel like?' })).toBeInTheDocument(); + expect(screen.queryByText(/Pick something useful/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/saved Discover/i)).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Open sessions' })); + expect(onOpenSessions).toHaveBeenCalledOnce(); }); - it('loads configured Watch content only after consent and keeps it isolated', () => { - vi.stubEnv('VITE_EXPLORE_WIDGET_URL', 'https://videos.example.test/embed'); + it('keeps the header, flexible content, and live dock as one bounded shell', () => { renderExplore(); + const content = document.querySelector('.explore-content'); + expect(content?.firstElementChild).toHaveClass('explore-header'); + expect(content?.lastElementChild).toHaveClass('live-copilot-dock'); + expect(document.querySelector('.agent-pulse')).not.toBeInTheDocument(); + expect(document.querySelector('.agent-activity-view')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Watch: Short visual content' })); - expect(screen.queryByTitle('External short video feed')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Load video feed' })); - - const frame = screen.getByTitle('External short video feed'); - expect(frame).toHaveAttribute('src', 'https://videos.example.test/embed'); - expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer'); - expect(frame).toHaveAttribute( - 'sandbox', - 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation', - ); - expect(frame.getAttribute('src')).not.toContain('channel-1'); - fireEvent.load(frame); - expect(screen.getByRole('status')).toHaveTextContent( - 'Provider navigation completed. Display readiness cannot be verified yet.', - ); - expect(screen.queryByText('The provider reported that its content is ready.')).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open provider directly' })).toHaveAttribute( - 'href', - 'https://videos.example.test/embed', - ); + fireEvent.click(screen.getByRole('button', { name: 'Play: Puzzles and mini-games' })); + expect(document.querySelector('.explore-tabs')?.nextElementSibling).toHaveClass('explore-category-body'); + expect(document.querySelector('.explore-category-body')?.nextElementSibling).toHaveClass('live-copilot-dock'); }); - it('times out to truthful unverified guidance without claiming the provider failed', () => { - vi.useFakeTimers(); - vi.stubEnv('VITE_EXPLORE_WIDGET_URL', 'https://videos.example.test/embed'); + it('integrates category detail with browser history without adding card entries', () => { renderExplore(); + fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); + expect(window.history.state).toEqual({ weftView: 'explore', exploreView: 'discover' }); + const before = window.history.length; + fireEvent.click(screen.getByRole('button', { name: 'Next Discover card' })); + expect(window.history.length).toBe(before); - fireEvent.click(screen.getByRole('button', { name: 'Watch: Short visual content' })); - fireEvent.click(screen.getByRole('button', { name: 'Load video feed' })); - const frame = screen.getByTitle('External short video feed'); - fireEvent.load(frame); - act(() => vi.advanceTimersByTime(12_000)); + act(() => { + window.dispatchEvent(new PopStateEvent('popstate', { state: { weftView: 'explore' } })); + }); + expect(screen.getByRole('heading', { name: 'What do you feel like?' })).toBeInTheDocument(); + }); - expect(screen.getByRole('alert')).toHaveTextContent('Weft could not verify the embedded display'); - expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); - expect(frame).toBeInTheDocument(); + it('builds a deterministic balanced deck with no repeats', () => { + const first = buildDiscoverDeck(DISCOVER_CARDS, 12345); + const second = buildDiscoverDeck(DISCOVER_CARDS, 12345); + expect(first.map((card) => card.id)).toEqual(second.map((card) => card.id)); + expect(new Set(first.map((card) => card.id)).size).toBe(DISCOVER_CARDS.length); + expect(first).toHaveLength(DISCOVER_CARDS.length); + for (let index = 1; index < first.length; index += 1) { + expect(first[index].topic).not.toBe(first[index - 1].topic); + } }); - it('accepts optional readiness only from the configured provider origin and sends no context', () => { - vi.stubEnv('VITE_EXPLORE_WIDGET_URL', 'https://videos.example.test/embed'); + it('shows one complete card, advances with controls and keyboard, and persists position', () => { renderExplore(); - fireEvent.click(screen.getByRole('button', { name: 'Watch: Short visual content' })); - fireEvent.click(screen.getByRole('button', { name: 'Load video feed' })); - const frame = screen.getByTitle('External short video feed') as HTMLIFrameElement; - fireEvent.load(frame); + fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); + const deck = screen.getByRole('region', { name: 'Discover card deck' }); + expect(document.querySelectorAll('.discover-deck-card')).toHaveLength(1); + const firstTitle = document.querySelector('.discover-deck-card h1')?.textContent; - act(() => { - window.dispatchEvent(new MessageEvent('message', { - origin: 'https://attacker.example.test', - source: frame.contentWindow, - data: { type: 'weft:explore-ready', channelId: 'leak' }, - })); - }); - expect(screen.queryByText('The provider reported that its content is ready.')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next Discover card' })); + expect(document.querySelector('.discover-deck-card h1')?.textContent).not.toBe(firstTitle); + expect(JSON.parse(localStorage.getItem('weft.explore.v1') ?? '{}').discoverIndex).toBe(1); - act(() => { - window.dispatchEvent(new MessageEvent('message', { - origin: 'https://videos.example.test', - source: frame.contentWindow, - data: { type: 'weft:explore-ready' }, - })); - }); - expect(screen.getByRole('status')).toHaveTextContent('The provider reported that its content is ready.'); - expect(frame.src).toBe('https://videos.example.test/embed'); - expect(frame.src).not.toContain('channel-1'); + fireEvent.keyDown(deck, { key: 'ArrowDown' }); + expect(document.querySelector('.discover-deck-card h1')?.textContent).toBe(firstTitle); }); - it('keeps an arriving approval operable above a loaded Watch frame', () => { - vi.stubEnv('VITE_EXPLORE_WIDGET_URL', 'https://videos.example.test/embed'); - const onApprove = vi.fn(); - const props = { - onBack: vi.fn(), - onOpenChat: vi.fn(), - onApprove, - onElicitationRespond: vi.fn(), - }; - const { rerender } = render(); - fireEvent.click(screen.getByRole('button', { name: 'Watch: Short visual content' })); - fireEvent.click(screen.getByRole('button', { name: 'Load video feed' })); - fireEvent.load(screen.getByTitle('External short video feed')); + it('responds only to dominant vertical swipes and keeps Save local', () => { + renderExplore(); + fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); + const deck = screen.getByRole('region', { name: 'Discover card deck' }); + const firstTitle = document.querySelector('.discover-deck-card h1')?.textContent; - rerender( - , - ); + fireEvent.pointerDown(deck, { clientX: 10, clientY: 100 }); + fireEvent.pointerUp(deck, { clientX: 100, clientY: 80 }); + expect(document.querySelector('.discover-deck-card h1')?.textContent).toBe(firstTitle); - expect(screen.getByTitle('External short video feed')).toBeInTheDocument(); - expect(screen.getByRole('dialog', { name: 'Agent needs your attention' })).toBeInTheDocument(); - const content = document.querySelector('.explore-content'); - expect(content).toHaveAttribute('aria-hidden', 'true'); - expect(content).toHaveAttribute('inert'); - expect(screen.getByRole('button', { name: 'Approve' })).toHaveFocus(); - fireEvent.click(screen.getByRole('button', { name: 'Approve' })); - expect(onApprove).toHaveBeenCalledWith('watch-approval', 'approve'); + fireEvent.pointerDown(deck, { clientX: 10, clientY: 100 }); + fireEvent.pointerUp(deck, { clientX: 12, clientY: 20 }); + expect(document.querySelector('.discover-deck-card h1')?.textContent).not.toBe(firstTitle); + + fireEvent.click(screen.getByRole('button', { name: /^Save / })); + expect(localStorage.getItem('weft.explore.v1')).toContain('savedCardIds'); + expect(screen.queryByRole('group', { name: 'Discover topics' })).not.toBeInTheDocument(); + }); + + it('starts a new shuffled cycle only after the current deck is exhausted', () => { + const today = new Date(); + const day = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + localStorage.setItem('weft.explore.v1', JSON.stringify({ + lastCategory: 'discover', + savedCardIds: [], + completedCardIds: [], + discoverDay: day, + discoverCycle: 2, + discoverIndex: DISCOVER_CARDS.length - 1, + threadlineCompleted: 0, + signalBest: 0, + vibration: false, + })); + renderExplore(); + fireEvent.click(screen.getByRole('button', { name: 'Discover: Read something worth knowing' })); + expect(screen.getByText(`50 / ${DISCOVER_CARDS.length}`)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next Discover card' })); + expect(JSON.parse(localStorage.getItem('weft.explore.v1') ?? '{}')).toMatchObject({ + discoverCycle: 3, + discoverIndex: 0, + }); + expect(screen.getByText(`1 / ${DISCOVER_CARDS.length}`)).toBeInTheDocument(); }); - it('traps interruption focus and restores the prior Watch focus after dismissal', () => { + it('loads configured Watch content only after consent and keeps it isolated', () => { vi.stubEnv('VITE_EXPLORE_WIDGET_URL', 'https://videos.example.test/embed'); - const props = { - onBack: vi.fn(), - onOpenChat: vi.fn(), - onApprove: vi.fn(), - onElicitationRespond: vi.fn(), - }; - const { rerender } = render(); + renderExplore(); fireEvent.click(screen.getByRole('button', { name: 'Watch: Short visual content' })); fireEvent.click(screen.getByRole('button', { name: 'Load video feed' })); const frame = screen.getByTitle('External short video feed'); - frame.focus(); - - rerender( - , - ); - - const approve = screen.getByRole('button', { name: 'Approve' }); - const deny = screen.getByRole('button', { name: 'Deny' }); - expect(approve).toHaveFocus(); - deny.focus(); - fireEvent.keyDown(deny, { key: 'Tab' }); - expect(approve).toHaveFocus(); - fireEvent.keyDown(approve, { key: 'Tab', shiftKey: true }); - expect(deny).toHaveFocus(); - - rerender(); - expect(frame).toHaveFocus(); - expect(document.querySelector('.explore-content')).not.toHaveAttribute('aria-hidden'); - expect(document.querySelector('.explore-content')).not.toHaveAttribute('inert'); - }); - - it('focuses the dialog when a disconnected interruption has no enabled actions', () => { - const props = { - onBack: vi.fn(), - onOpenChat: vi.fn(), - onApprove: vi.fn(), - onElicitationRespond: vi.fn(), - }; - const { rerender } = render(); - const underlying = screen.getByRole('button', { name: 'Back to chat' }); - underlying.focus(); - expect(underlying).toHaveFocus(); - - rerender( - , + expect(frame).toHaveAttribute('src', 'https://videos.example.test/embed'); + expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(frame).toHaveAttribute( + 'sandbox', + 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation', ); - - const dialog = screen.getByRole('dialog', { name: 'Agent needs your attention' }); - expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); - expect(document.querySelector('.explore-content')).toHaveAttribute('inert'); - expect(underlying).not.toHaveFocus(); - expect(dialog).toHaveAttribute('tabindex', '-1'); - expect(dialog).toHaveFocus(); + fireEvent.load(frame); + expect(screen.getByRole('status')).toHaveTextContent('Display readiness cannot be verified yet.'); }); }); @@ -452,7 +403,7 @@ describe('Threadline logic', () => { ])).toBe(0); }); - it('rejects coincident nodes, overlaps, endpoint touches, and nodes on unrelated edges', () => { + it('rejects coincident nodes and nodes on unrelated edges', () => { const planar = [ { id: 0, x: 50, y: 10 }, { id: 1, x: 85, y: 30 }, @@ -461,26 +412,9 @@ describe('Threadline logic', () => { { id: 4, x: 15, y: 70 }, { id: 5, x: 15, y: 30 }, ]; - expect(countThreadlineCrossings(planar.map((point) => point.id === 5 ? { ...point, x: 15, y: 70 } : point, ))).toBeGreaterThan(0); - expect(countThreadlineCrossings([ - { id: 0, x: 10, y: 10 }, - { id: 1, x: 30, y: 10 }, - { id: 2, x: 50, y: 10 }, - { id: 3, x: 80, y: 80 }, - { id: 4, x: 10, y: 80 }, - { id: 5, x: 80, y: 30 }, - ])).toBeGreaterThan(0); - expect(countThreadlineCrossings([ - { id: 0, x: 10, y: 10 }, - { id: 1, x: 40, y: 40 }, - { id: 2, x: 40, y: 40 }, - { id: 3, x: 70, y: 10 }, - { id: 4, x: 80, y: 80 }, - { id: 5, x: 10, y: 80 }, - ])).toBeGreaterThan(0); expect(countThreadlineCrossings(planar.map((point) => point.id === 5 ? { ...point, x: 67.5, y: 20 } : point, ))).toBeGreaterThan(0); diff --git a/mobile/src/ui/screens/SessionScreen.tsx b/mobile/src/ui/screens/SessionScreen.tsx index 58bca09..0d7ca2e 100644 --- a/mobile/src/ui/screens/SessionScreen.tsx +++ b/mobile/src/ui/screens/SessionScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import type { JSX } from 'react'; import type { PromptAttachment, PromptDelivery, SessionMode } from '@aasis21/weft-shared'; import type { SessionView } from '@/session/view'; @@ -16,6 +16,10 @@ import type { VoiceState } from '@/ui/voice/useVoxEngine'; import { getStableDeviceId } from '@/lib/weftClient'; import { isDesktopInput, useIsWideViewport } from '@/lib/platform'; +const ExploreScreen = lazy(() => + import('@/ui/explore/ExploreScreen').then((module) => ({ default: module.ExploreScreen })), +); + function pickString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } @@ -205,6 +209,8 @@ interface SessionScreenProps { onSelectSession(channelId: string): void; onAddSession(): void; onOpenExplore?(): void; + exploreOpen?: boolean; + onCloseExplore?(): void; onStartSession?(): void; onOpenDevices?(): void; /** #186 nav simplification: registered listener devices, surfaced in the drawer so picking one @@ -240,6 +246,8 @@ export function SessionScreen({ onSelectSession, onAddSession, onOpenExplore, + exploreOpen = false, + onCloseExplore, onStartSession, onOpenDevices, devices, @@ -582,6 +590,11 @@ export function SessionScreen({ return (
    +
    {isDesktopWide ? ( sidebarCollapsed ? (
    +
    + + {exploreOpen && onCloseExplore ? ( + Opening Explore…}> + setDrawerOpen(true)} + onOpenChat={onCloseExplore} + onGoHome={onGoHome} + desktopDocked={isDesktopWide} + /> + + ) : null} {!isDesktopWide && drawerOpen ? ( div { + display: flex; + gap: 5px; +} + +.discover-deck-actions > div button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--bg-rule); + border-radius: 11px; + color: var(--fg); + background: var(--bg); +} + +.discover-deck-actions > div button:disabled { + opacity: 0.35; +} + +.watch-view, +.watch-consent, +.watch-empty { + height: 100%; + min-height: 0; +} + +.watch-consent, +.watch-empty { + margin: 0 auto; + padding: 18px; +} + +.watch-frame-shell { + height: 100%; + min-height: 0; +} + +.activity-picker { + height: 100%; + min-height: 0; + padding: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.activity-card { + min-height: 0; + padding: 10px; + grid-template-columns: 44px minmax(0, 1fr); + gap: 9px; +} + +.activity-preview { + width: 44px; + height: 58px; + border-radius: 13px; +} + +.activity-preview svg { + width: 27px; + height: 27px; +} + +.activity-card strong { + font-size: 16px; +} + +.activity-card b { + grid-column: 2; +} + +@media (max-height: 700px) { + .explore-header { + min-height: 48px; + padding-block: max(5px, env(safe-area-inset-top)) 5px; + } + .explore-home { padding-block: 8px 6px; } + .explore-intro { margin-bottom: 6px; } + .explore-choice { padding: 10px; } + .explore-choice-icon { width: 32px; height: 32px; } + .live-copilot-dock { + min-height: 76px; + max-height: 82px; + margin-block: 6px max(6px, env(safe-area-inset-bottom)); + } + .discover-view { padding-block: 7px; } + .discover-deck-illustration { display: none; } + .discover-deck-insight { margin-top: 9px; padding: 9px 11px; } + .game-view, + .unwind-session, + .watch-view { + padding: 10px 12px; + } + .game-heading { margin-bottom: 8px; } + .game-heading h1, + .unwind-session h1, + .watch-view h1 { font-size: 23px; } + .threadline-board { + width: min(100%, calc(100dvh - 300px), 400px); + } + .signal-grid { + width: min(100%, calc(100dvh - 300px), 350px); + margin-block: 10px; + gap: 7px; + } + .weave-breath-visual { + width: min(42vh, 190px); + margin-block: 12px 8px; + } + .horizon-visual { + height: 112px; + margin-block: 12px 8px; + } + .activity-card p, + .activity-card b { + display: none; + } +} + +@media (min-width: 700px) { + .explore-choice { min-height: 0; } + .live-copilot-dock { + width: min(calc(100% - 24px), 760px); + margin-inline: auto; + } + .activity-card { + grid-template-columns: 64px minmax(0, 1fr); + } + .activity-preview { + width: 64px; + height: 82px; + } +} + +@media (prefers-reduced-motion: reduce) { + .live-copilot-dock-working .live-copilot-dot { + animation: none; + } +} diff --git a/openspec/changes/refine-explore-live-experience/.openspec.yaml b/openspec/changes/refine-explore-live-experience/.openspec.yaml new file mode 100644 index 0000000..abd7c5a --- /dev/null +++ b/openspec/changes/refine-explore-live-experience/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-25 diff --git a/openspec/changes/refine-explore-live-experience/design.md b/openspec/changes/refine-explore-live-experience/design.md new file mode 100644 index 0000000..1a1de7a --- /dev/null +++ b/openspec/changes/refine-explore-live-experience/design.md @@ -0,0 +1,109 @@ +## Context + +Explore 0.2.24 is a sibling application view layered above a still-mounted `SessionScreen`. It currently owns a Back button, saved-card action, top Agent Pulse, optional Agent Activity screen, Weft-owned interruption overlay, Discover filters/feed/reader, and category-specific activities. The active `SessionView` already exposes streaming assistant items assembled from `assistant_delta`, live intent, running tools, busy timing, approvals, elicitations, unread state, errors, and session status. + +The redesigned surface must fit normal phone viewports without page scrolling, preserve chat state, reuse the established session drawer, avoid a second activity/details destination, and remain accessible with touch, keyboard, screen readers, reduced motion, and enlarged text. + +## Goals / Non-Goals + +**Goals:** + +- Make live Copilot work continuously visible in one fixed bottom dock. +- Stream current assistant text in place and compact all fallback activity states into the same region. +- Return directly to chat when the dock is activated. +- Give Explore the same hamburger/session-drawer affordance as the active chat. +- Make Discover a one-card-at-a-time, vertically swipeable, balanced deck with no filters, list, or reader transition. +- Fit the Explore home and normal category experiences inside `100dvh` without page scrolling. +- Preserve local reading, saving, game, and unwind state through the existing bounded storage key. + +**Non-Goals:** + +- Supporting non-GitHub-Copilot agents or a generic agent protocol. +- Building recommendations, accounts, cloud personalization, or a saved-card library. +- Adding new protocol messages; all live dock data comes from the existing decrypted session projection. +- Turning Explore into a mobile IDE, file browser, or diff viewer in this change. +- Guaranteeing zero scrolling at extreme accessibility text scales where clipping would be worse. + +## Decisions + +### Explore is rendered inside the active session shell + +`SessionScreen` will host the Explore overlay so its existing drawer controller and `SessionDrawer` remain authoritative. The App shell will continue to own browser history state, but it will pass Explore-open state and close callbacks into the active session screen rather than rendering an independent sibling that cannot open the drawer. + +Alternative considered: duplicate the drawer or lift all drawer state and rendering into `App.tsx`. Rejected because it would create a second navigation implementation or broaden App ownership unnecessarily. + +### The header is a stable shared-navigation header + +Explore will show the existing unread-aware hamburger on mobile, the existing static Weft mark on desktop-wide layouts, a compass mark, and the single title `Explore`. It will have no Back action, subtitle, saved-card action, count, or right-side control. + +System/browser Back remains hierarchical: category or activity detail returns to Explore home, then the next Back returns to chat. Internal games and unwind activities retain small in-content return controls. + +### Live Copilot Dock derives from the current timeline + +A pure projection will choose dock content in this order: + +1. Pending approval or elicitation. +2. Session error or ended state. +3. Latest non-final assistant text while busy. +4. Current intent. +5. Latest running tool. +6. Completed unread reply preview. +7. Ready or recent activity. + +The dock uses a fixed height and renders a status row plus at most two lines of normalized live text and one compact tool/detail line. Streaming updates replace content in place rather than growing the layout. Activating any dock state returns to chat. Approvals and elicitations are handled by the existing chat cards; Explore no longer renders a duplicate interruption dialog. + +Alternative considered: keep Agent Pulse plus an Agent Activity destination. Rejected because it duplicates chat, consumes vertical space, and makes users choose between two representations of the same work. + +### Discover is one complete card, not a feed plus reader + +Discover will render one card that includes topic, duration, title, summary, useful insight, optional illustration, Save, and deck progress. The existing repeated explanatory paragraph is removed. Topic filters, feed cards, saved-list header entry, and reader state are removed. + +Vertical swipe up advances and swipe down returns to the previous card. Arrow keys and explicit accessible Previous/Next controls provide equivalent operation. Horizontal gestures remain unused to avoid conflict with platform Back behavior. + +### The deck is balanced, deterministic, and locally resumable + +The deck builder will: + +- group cards by topic, +- deterministically shuffle each topic bucket from a daily seed, +- interleave buckets so adjacent cards vary when possible, +- rotate the starting topic by seed, +- include every catalog card once before repeating, +- persist seed/day and current index, +- advance to a new seeded cycle only after exhaustion. + +Saving remains a local signal for future personalization but does not expose a saved-card view. The first release does not alter deck order using saved or completed state. + +Alternative considered: `Array.sort(() => Math.random() - 0.5)` on each mount. Rejected because it is biased, unstable across renders, clusters topics, and cannot restore position. + +### Viewport fitting is structural, not clipping + +Explore uses a `100dvh` grid with fixed header, optional tabs, flexible content, and fixed Live Copilot Dock. The home removes the introductory paragraph and Continue card; four category choices use a compact `2 x 2` grid. Category content uses `minmax(0, 1fr)` and no page overflow. + +Discover catalog tests enforce concise title, summary, and insight limits so normal cards fit without CSS line-clamping away content. Height breakpoints reduce decoration and spacing before reducing readable text. At extreme text scaling, the flexible content region may scroll internally while the global page and dock remain stable. + +### History records detail presence, not every card + +Entering a category or activity detail creates at most one Explore-detail history entry. Swiping Discover cards and switching categories update local state without adding browser history entries. This preserves predictable system Back behavior and avoids dozens of history entries during casual swiping. + +## Risks / Trade-offs + +- **Streaming text changes too rapidly to read** → Normalize whitespace, show only the newest bounded excerpt, and update in place without marquee motion. +- **A long card overflows short phones** → Enforce content limits, remove decorative media at short heights, and allow accessibility-only internal scrolling. +- **Vertical swipe conflicts with card scrolling** → Standard cards do not scroll; accessibility overflow mode disables swipe capture while content is actively scrolling. +- **Drawer integration destabilizes SessionScreen** → Reuse its existing state and component, keep Explore as an overlay within the same mounted shell, and add App/session integration tests. +- **Daily order feels repetitive** → Persist a complete cycle, rotate by day, and reseed after catalog exhaustion without repeating within a cycle. +- **Removing Explore approval overlays delays action** → The dock uses a high-contrast attention state and returns directly to the canonical chat card. +- **Removing the saved library makes Save feel incomplete** → Treat Save explicitly as a lightweight “remember this” signal; do not advertise browsing saved content until that product is designed. + +## Migration Plan + +1. Extend the existing Explore storage record with optional deck day, seed, and index fields; tolerate all 0.2.24 records. +2. Keep existing saved and completed card IDs. +3. Remove obsolete topic/reader/activity-detail UI without deleting compatible stored fields. +4. Ship through the standard versioned `ship.ps1` production pipeline. +5. Roll back by restoring the 0.2.24 Explore module; additive storage fields remain harmless. + +## Open Questions + +None. The agreed interaction is fixed: shared hamburger header, bottom live dock returning to chat, and balanced vertical Discover cards with no list or filters. diff --git a/openspec/changes/refine-explore-live-experience/proposal.md b/openspec/changes/refine-explore-live-experience/proposal.md new file mode 100644 index 0000000..66cf185 --- /dev/null +++ b/openspec/changes/refine-explore-live-experience/proposal.md @@ -0,0 +1,35 @@ +## Why + +Explore currently behaves like a nested content page: Agent Pulse occupies premium space at the top, opens a second activity screen, and Discover requires choosing filters and cards before reading. The phone experience should instead keep Copilot visibly alive in one compact bottom dock while every Explore category fits the viewport and Discover removes choice paralysis through a single swipeable idea deck. + +## What Changes + +- Replace the top Agent Pulse and Agent Activity screen with a persistent bottom Live Copilot Dock. +- Stream the latest assistant text in the dock, with intent, active tool, elapsed work, reply-ready, blocked, error, and idle fallbacks compacted into the same fixed space. +- Make the dock return directly to the active chat when activated. +- Replace the Explore Back and saved-card header actions with the existing sessions hamburger, compass mark, and a compact Explore title. +- Reuse the existing session drawer rather than introducing an Explore-specific menu. +- Replace Discover topic filters, multi-card feed, and separate reader with one complete vertically swipeable card at a time. +- Build a deterministic balanced deck that varies topics, avoids repeats until exhaustion, and preserves position locally. +- Keep per-card Save as a local signal for later personalization without exposing a saved-content list or personalization controls. +- Reflow Explore home, Watch, Play, and Unwind into a fixed `100dvh` shell with no page scrolling at standard mobile text sizes. +- Preserve safe internal scrolling only when accessibility text scaling makes it necessary. + +## Capabilities + +### New Capabilities + +- `mobile-explore-live-experience`: Compact Explore shell, shared session navigation, live Copilot dock, swipeable balanced Discover deck, viewport fitting, persistence, and accessibility behavior. + +### Modified Capabilities + +None. + +## Impact + +- Mobile application shell and session-drawer ownership. +- Explore navigation, live session projection, interruptions, and chat return behavior. +- Discover presentation, ordering, gestures, persistence, and catalog constraints. +- Explore responsive styling and safe-area handling. +- Focused App, session-header, Explore, persistence, gesture, reduced-motion, and accessibility tests. +- Explore design, setup, and privacy documentation where behavior descriptions change. diff --git a/openspec/changes/refine-explore-live-experience/specs/mobile-explore-live-experience/spec.md b/openspec/changes/refine-explore-live-experience/specs/mobile-explore-live-experience/spec.md new file mode 100644 index 0000000..ce69403 --- /dev/null +++ b/openspec/changes/refine-explore-live-experience/specs/mobile-explore-live-experience/spec.md @@ -0,0 +1,127 @@ +## ADDED Requirements + +### Requirement: Shared Explore header +Explore SHALL use the established Weft sessions navigation affordance rather than a nested-page Back control. + +#### Scenario: Open sessions from Explore +- **WHEN** the user activates the three-line button in Explore +- **THEN** the application opens the same session drawer used by the active chat + +#### Scenario: Compact header +- **WHEN** Explore is displayed on mobile +- **THEN** the header contains the sessions button, compass mark, and Explore title without a Back button, subtitle, saved-card icon, count, or right-side action + +#### Scenario: Desktop-wide navigation +- **WHEN** the persistent desktop session sidebar is visible +- **THEN** Explore shows the established static Weft mark instead of a redundant hamburger + +### Requirement: Live Copilot Dock +Explore SHALL display a fixed bottom dock that projects live state from the active GitHub Copilot CLI session. + +#### Scenario: Stream assistant text +- **WHEN** an assistant response is arriving through assistant deltas +- **THEN** the dock updates the latest normalized text in place using at most two visible lines without changing height + +#### Scenario: Show work before text arrives +- **WHEN** the session is busy but has no streamed assistant text +- **THEN** the dock shows current intent or the latest running tool with elapsed work time + +#### Scenario: Work needs attention +- **WHEN** an approval or elicitation is pending +- **THEN** the dock shows a visually and semantically distinct attention state describing the blocked action + +#### Scenario: Reply is ready +- **WHEN** the assistant completes while the user remains in Explore +- **THEN** the dock shows Reply ready with a bounded response preview without navigating away + +#### Scenario: Return to chat +- **WHEN** the user activates any Live Copilot Dock state +- **THEN** the application returns directly to the active conversation + +#### Scenario: No duplicate activity surface +- **WHEN** the user interacts with live status in Explore +- **THEN** the application does not open a separate Agent Activity view, step list, approval dialog, or elicitation form inside Explore + +### Requirement: Swipeable Discover deck +Discover SHALL present one complete useful-idea card at a time without topic filters, a multi-card list, or a separate reader screen. + +#### Scenario: Open Discover +- **WHEN** the user selects Discover +- **THEN** the application immediately shows one complete card with topic, duration, title, summary, useful insight, Save action, and deck position + +#### Scenario: Advance by touch +- **WHEN** the user swipes upward beyond the gesture threshold +- **THEN** Discover advances exactly one card + +#### Scenario: Return by touch +- **WHEN** the user swipes downward beyond the gesture threshold and a prior card exists +- **THEN** Discover returns exactly one card + +#### Scenario: Operate without touch +- **WHEN** the user presses Arrow Up, Arrow Down, or the accessible Previous and Next controls +- **THEN** Discover performs the equivalent single-card navigation + +#### Scenario: Avoid platform gesture conflict +- **WHEN** the user makes a predominantly horizontal gesture +- **THEN** Discover does not change cards or suppress platform Back navigation + +### Requirement: Balanced deterministic discovery +Discover SHALL generate a locally deterministic deck that varies topics and avoids repeats until the catalog is exhausted. + +#### Scenario: Build a daily deck +- **WHEN** a Discover cycle begins +- **THEN** the application deterministically shuffles cards within topics and interleaves topics using a local daily seed + +#### Scenario: Avoid adjacent topic clusters +- **WHEN** cards from multiple topics remain available +- **THEN** the deck avoids adjacent cards from the same topic when an alternative topic is available + +#### Scenario: Resume position +- **WHEN** the user leaves and later reopens Discover on the same cycle +- **THEN** the application restores the same deck and current card + +#### Scenario: Exhaust a cycle +- **WHEN** the user reaches the final unseen card +- **THEN** the next advance begins a newly seeded complete cycle without losing saved-card state + +#### Scenario: Save without filtering +- **WHEN** the user saves or unsaves the current card +- **THEN** the application persists that local signal without opening or exposing a saved-card list + +### Requirement: Single-screen Explore layout +Explore SHALL fit its standard mobile experiences within the dynamic viewport without page scrolling. + +#### Scenario: Explore home +- **WHEN** Explore home is displayed at a supported standard phone size +- **THEN** the compact header, four `2 x 2` category choices, and Live Copilot Dock are simultaneously visible without page scrolling or a Continue card + +#### Scenario: Category view +- **WHEN** a category is displayed at a supported standard phone size +- **THEN** the header, compact category tabs, category content, and Live Copilot Dock are simultaneously visible without page scrolling + +#### Scenario: Short viewport +- **WHEN** viewport height is below the comfortable breakpoint +- **THEN** decorative illustrations and spacing reduce before readable text or controls are removed + +#### Scenario: Enlarged accessibility text +- **WHEN** accessibility text scaling prevents content from fitting safely +- **THEN** only the flexible content region may scroll while the global header and Live Copilot Dock remain stable + +#### Scenario: Reduced motion +- **WHEN** the operating system requests reduced motion +- **THEN** dock updates and card changes preserve all state information without sliding or marquee animation + +### Requirement: Explore navigation history +Explore SHALL preserve predictable system Back behavior without recording every card swipe. + +#### Scenario: Back from a category +- **WHEN** the user invokes system or browser Back from an Explore category or activity detail +- **THEN** the application returns to Explore home + +#### Scenario: Back from Explore home +- **WHEN** the user invokes system or browser Back from Explore home +- **THEN** the application returns to the preserved active chat + +#### Scenario: Swipe cards +- **WHEN** the user navigates between Discover cards +- **THEN** the application does not create browser history entries for individual cards diff --git a/openspec/changes/refine-explore-live-experience/tasks.md b/openspec/changes/refine-explore-live-experience/tasks.md new file mode 100644 index 0000000..a6bf333 --- /dev/null +++ b/openspec/changes/refine-explore-live-experience/tasks.md @@ -0,0 +1,43 @@ +## 1. Shared Explore Shell + +- [x] 1.1 Render Explore within the mounted active session shell so it can reuse the existing session drawer +- [x] 1.2 Replace the Explore Back and saved-card header actions with the mobile hamburger or desktop Weft mark, compass, and compact title +- [x] 1.3 Preserve system Back behavior from category or activity to Explore home and then chat +- [x] 1.4 Reflow the Explore shell into fixed header, optional tabs, flexible content, and bottom dock viewport bands +- [x] 1.5 Compact Explore home to a no-scroll title and `2 x 2` category grid without the introductory paragraph or Continue card + +## 2. Live Copilot Dock + +- [x] 2.1 Implement a pure dock projection for attention, error, streaming text, intent, running tool, reply-ready, and idle priorities +- [x] 2.2 Render normalized streaming assistant text in a fixed-height two-line bottom dock +- [x] 2.3 Show elapsed work and compact intent or tool detail without growing the dock +- [x] 2.4 Make all dock states return directly to the active chat +- [x] 2.5 Remove Agent Pulse, Agent Activity, and Explore-owned approval and elicitation overlays +- [x] 2.6 Add focused tests for streaming updates, priority ordering, fixed content bounds, attention states, and chat return + +## 3. Swipeable Discover Deck + +- [x] 3.1 Replace topic filters, feed cards, and reader state with one complete Discover card +- [x] 3.2 Implement deterministic per-topic shuffling and balanced topic interleaving from a daily seed +- [x] 3.3 Persist deck day, cycle seed, and current index while migrating existing Explore storage safely +- [x] 3.4 Implement vertical swipe thresholds for next and previous without intercepting horizontal gestures +- [x] 3.5 Add Arrow Up, Arrow Down, and accessible Previous and Next controls +- [x] 3.6 Keep per-card Save as local state without a saved-card list, filter, icon, or count +- [x] 3.7 Add catalog length constraints and remove repeated reader-only copy so every standard card fits +- [x] 3.8 Add logic and interaction tests for deterministic balance, no repeats, persistence, gestures, keyboard operation, cycle exhaustion, and saving + +## 4. Category Viewport Fitting + +- [x] 4.1 Fit Watch, Play, and Unwind pickers inside the flexible viewport region without page scrolling +- [x] 4.2 Scale Threadline, Signal Steps, Weave Breath, and Eye Horizon controls for short phones +- [x] 4.3 Add short-height and reduced-motion styling that removes decoration before readable content +- [x] 4.4 Preserve accessibility-only internal scrolling for enlarged text without moving the global dock +- [x] 4.5 Add focused layout contract tests for home, categories, and dock-safe content padding + +## 5. Documentation and Validation + +- [x] 5.1 Update Explore documentation and privacy wording for the live dock, canonical chat actions, and shuffled local deck +- [x] 5.2 Run focused mobile tests and mobile test type-checking +- [x] 5.3 Run complete repository tests, build, lint, and version checks +- [x] 5.4 Review the final diff for navigation, streaming privacy, gesture conflicts, accessibility, viewport fitting, and unrelated changes +- [x] 5.5 Validate all OpenSpec artifacts and mark every completed task