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
9 changes: 5 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand Down
8 changes: 5 additions & 3 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/...'
Expand Down
42 changes: 13 additions & 29 deletions mobile/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } |
Expand Down Expand Up @@ -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}
Expand All @@ -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);
}}
Expand All @@ -562,31 +569,8 @@ export default function App(): JSX.Element {
);

return (
<>
<div
className="session-surface"
aria-hidden={exploreOpen || undefined}
{...(exploreOpen ? { inert: '' as unknown as boolean } : {})}
>
<Suspense fallback={loadingScreen('Opening your session…')}>
{sessionScreen}
</Suspense>
</div>
{exploreOpen ? (
<Suspense fallback={loadingScreen('Opening Explore…')}>
<ExploreScreen
active={active}
onBack={() => 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)
}
/>
</Suspense>
) : null}
</>
<Suspense fallback={loadingScreen('Opening your session…')}>
{sessionScreen}
</Suspense>
);
}
66 changes: 51 additions & 15 deletions mobile/src/app/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -77,27 +77,51 @@ vi.mock('@/ui/hooks/usePairing', () => ({
isNativeRuntime: () => false,
}));

vi.mock('@/ui/screens/LandingScreen', () => ({
LandingScreen: () => <main data-testid="landing-screen">Landing</main>,
}));

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 (
<main data-testid="session-screen">
<span>Local state {count}</span>
<button type="button" onClick={() => setCount((value) => value + 1)}>Increment local state</button>
<button type="button" onClick={onOpenExplore}>Open Explore</button>
</main>
<>
<main
data-testid="session-screen"
aria-hidden={exploreOpen || undefined}
{...(exploreOpen ? { inert: '' as unknown as boolean } : {})}
>
<span>Local state {count}</span>
<button type="button" onClick={() => setCount((value) => value + 1)}>Increment local state</button>
<button type="button" onClick={onOpenExplore}>Open Explore</button>
</main>
{exploreOpen ? (
<main data-testid="explore-screen">
Explore overlay
<button type="button" onClick={onGoHome}>Go Home</button>
</main>
) : null}
</>
);
},
}));

vi.mock('@/ui/explore/ExploreScreen', () => ({
ExploreScreen: () => <main data-testid="explore-screen">Explore overlay</main>,
}));

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(<App />);
Expand All @@ -108,16 +132,28 @@ 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 }));
});

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(<App />);
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);
});
});
Loading
Loading