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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,20 @@ while you use the phone.
six images, and use Vox for hands-free prompting.
- **Native approval relay** — allow once, allow for the session, or deny the exact action
Copilot requested.
- **Session control** — stop an active turn, queue the next instruction, use supported slash
commands, and switch between interactive, plan, and autopilot modes.
- **Session control** — stop an active turn, queue the next instruction, and use supported slash
commands through one searchable command flow. Commands can run directly, request text, or offer
a curated option list; `/model` exposes supported model choices without leaking internal IDs.
Weft reports command failures from Copilot instead of claiming a model change succeeded.
- **Multi-device workspace** — use one phone to move among paired laptops, registered
projects, and active or historical sessions.
- **Safe launch recovery** — reconnect to slow Start and Resume operations instead of
silently creating duplicate Copilot processes.
- **Shared terminal** — open and resume one real shell on supported Windows laptops. See
[terminal controls and access boundaries](docs/terminal.md).
- **Explore between turns** — use compact header tiles for Discover, Watch, Play, and
Unwind while real assistant and tool activity rolls through the bottom Copilot dock.
Unwind, or swipe left from the right edge of Chat to open Discover and return with one Back.
A concise Copilot presence tile shows the current activity and useful live response context
without turning Explore into a second debug log.
Discover provides a balanced animated swipe deck of 50 offline cards, alongside two
lightweight games, guided rest activities, and an optional explicitly loaded
third-party short-video widget.
Expand Down
2 changes: 2 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ <h3 id="session-controls"><a class="heading-link" href="#session-controls">Work
<div><dt>Approvals</dt><dd>Read and respond to the permission requests Copilot surfaces. A pending request can block progress until it is answered or the session ends; do not assume unattended requests expire automatically.</dd></div>
<div><dt>Interrupt</dt><dd>Use the session's stop control to request that the current turn stop. Keep the laptop connection available while it processes the request.</dd></div>
<div><dt>Modes</dt><dd>Choose interactive, plan, or autopilot when supported by the Copilot host. A mode change is not a replacement for the session's permission policy.</dd></div>
<div><dt>Slash commands</dt><dd>Choose a supported command from the composer. Commands can run immediately, request text, or offer a curated option list. For <code>/model</code>, Weft shows friendly model names while Copilot remains authoritative about whether the selection succeeds.</dd></div>
<div><dt>Explore</dt><dd>Open Discover, Watch, Play, or Unwind from the session header. On a phone, swipe left from the right edge of Chat to enter Discover directly; one Back returns to the same conversation. The Copilot tile keeps current session activity visible while you explore.</dd></div>
</dl>
<figure class="session-figure" id="action">
<a href="assets/session-transcript.webp"><img src="assets/session-transcript.webp" width="824" height="1790" alt="Weft session transcript with an inline Copilot permission request." loading="lazy"></a>
Expand Down
20 changes: 14 additions & 6 deletions extension/src/relay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
recentTurns,
stateSnapshot,
getPhoneCommand,
validatePhoneCommandInput,
} from "@aasis21/weft-shared";
import { readSummary, readHistory, readLatestTurnIndex } from "./store.mjs";
import { createRecentTurns } from "./recentTurns.mjs";
Expand Down Expand Up @@ -905,7 +906,6 @@ async function applyInterrupt(session, logger, sendSafe) {
// line so the phone sees the outcome even when the command emits no further session events.
async function applyCommand(session, body, logger, sendSafe) {
const command = getPhoneCommand(body?.name);
const rawInput = typeof body?.input === "string" ? body.input.trim() : "";
if (!command) {
logger(`Weft: ignored non-whitelisted command "/${body?.name ?? ""}" from phone.`, {
level: "warning",
Expand All @@ -914,9 +914,10 @@ async function applyCommand(session, body, logger, sendSafe) {
await sendSafe(logLine("warning", `Command /${body?.name ?? ""} isn't allowed from the phone.`));
return;
}
if (command.arg === "required" && !rawInput) {
logger(`Weft: /${command.name} needs an argument; ignored.`, { level: "warning", ephemeral: false });
await sendSafe(logLine("warning", `/${command.name} needs an argument.`));
const validated = validatePhoneCommandInput(command, body?.input);
if (!validated.valid) {
logger(`Weft: ${validated.error} Ignored phone request.`, { level: "warning", ephemeral: false });
await sendSafe(logLine("warning", validated.error));
return;
}
if (typeof session.rpc?.commands?.invoke !== "function") {
Expand All @@ -927,9 +928,16 @@ async function applyCommand(session, body, logger, sendSafe) {
await sendSafe(logLine("warning", `This CLI build can't run /${command.name} remotely.`));
return;
}
const shown = rawInput ? `/${command.name} ${rawInput}` : `/${command.name}`;
const input = validated.input;
const shownInput = validated.option?.label ?? input;
const shown = shownInput ? `/${command.name} ${shownInput}` : `/${command.name}`;
try {
await session.rpc.commands.invoke(rawInput ? { name: command.name, input: rawInput } : { name: command.name });
const result = await session.rpc.commands.invoke(
input ? { name: command.name, input } : { name: command.name },
);
if (result?.success === false) {
throw new Error(result.error?.message ?? result.error ?? result.message ?? "Command was rejected by the CLI.");
}
logger(`Weft: ran ${shown} from phone.`, { level: "info", ephemeral: false });
await sendSafe(logLine("info", `▷ Ran ${shown} from your phone.`));
} catch (err) {
Expand Down
41 changes: 41 additions & 0 deletions extension/test/relay.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,47 @@ test("refuses a required-arg command with no argument", async () => {
});
});

test("canonicalizes an approved model label before invoking the SDK", async () => {
await withRelay(async ({ channel, session }) => {
channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "Claude Sonnet 5"));
await flush();
assert.deepEqual(session.invokedCommands, [{ name: "model", input: "claude-sonnet-5" }]);
const ok = channel.sent.find(
(m) => m.eventSubtype === SUBTYPE.STREAM.LOG && /Ran \/model Claude Sonnet 5/.test(m.msg.message ?? "")
);
assert.ok(ok, "expected the friendly model label in the success notice");
assert.equal(channel.sent.some((m) => /claude-sonnet-5/.test(m.msg.message ?? "")), false);
});
});

test("refuses an unlisted model before invoking the SDK", async () => {
await withRelay(async ({ channel, session }) => {
channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "unlisted-model"));
await flush();
assert.deepEqual(session.invokedCommands, []);
const warn = channel.sent.find(
(m) => m.eventSubtype === SUBTYPE.STREAM.LOG && /isn't available for \/model/i.test(m.msg.message ?? "")
);
assert.ok(warn, "expected an unavailable-option warning relayed to the phone");
});
});

test("relays a resolved SDK command failure without a success-shaped fallback", async () => {
await withRelay(async ({ channel, session }) => {
session.rpc.commands.invoke = async (params) => {
session.invokedCommands.push(params);
return { success: false, error: { message: "Model unavailable for this account" } };
};

channel.emit(EVENT_TYPE.CONTROL, invokeCommand("model", "auto"));
await flush();

const logs = channel.sent.filter((m) => m.eventSubtype === SUBTYPE.STREAM.LOG);
assert.ok(logs.some((m) => /\/model Auto failed: Model unavailable/i.test(m.msg.message ?? "")));
assert.equal(logs.some((m) => /Ran \/model Auto/i.test(m.msg.message ?? "")), false);
});
});

test("forwards turn lifecycle as activity busy=true on message_start, false on idle", async () => {
await withRelay(async ({ channel, session }) => {
// A turn begins with the assistant streaming text (no tool yet) — Stop must show here.
Expand Down
42 changes: 35 additions & 7 deletions mobile/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const TerminalScreen = lazy(() =>

type ModalHistoryState = { weftView: 'devices' } |
{ weftView: 'device-details' | 'terminal'; channelId: string } |
{ weftView: 'explore'; exploreView?: string } | null;
{ weftView: 'explore'; exploreView?: string; entry?: 'direct-discover' } | null;

function loadingScreen(label: string): JSX.Element {
return (
Expand All @@ -52,6 +52,7 @@ export default function App(): JSX.Element {
const [deviceDetailsChannelId, setDeviceDetailsChannelId] = useState<string | undefined>(undefined);
const [terminalChannelId, setTerminalChannelId] = useState<string | undefined>();
const [exploreOpen, setExploreOpen] = useState(false);
const [exploreEntry, setExploreEntry] = useState<'home' | 'direct-discover'>('home');
const [addManual, setAddManual] = useState(false);
const [showLanding, setShowLanding] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -73,6 +74,11 @@ export default function App(): JSX.Element {
const onPopState = (event: PopStateEvent): void => {
const state = event.state as ModalHistoryState;
setExploreOpen(state?.weftView === 'explore');
setExploreEntry(
state?.weftView === 'explore' && state.entry === 'direct-discover'
? 'direct-discover'
: 'home',
);
setTerminalChannelId(state?.weftView === 'terminal' ? state.channelId : undefined);
if (state?.weftView === 'devices') {
setDevicesOpen(true);
Expand Down Expand Up @@ -196,10 +202,33 @@ export default function App(): JSX.Element {
setDevicesOpen(false);
setDeviceDetailsChannelId(undefined);
setTerminalChannelId(undefined);
setExploreEntry('home');
setExploreOpen(true);
window.history.pushState({ weftView: 'explore' } satisfies ModalHistoryState, '');
}, []);

const openDiscoverFromChat = useCallback((): void => {
setError(null);
setExploreEntry('direct-discover');
setExploreOpen(true);
window.history.pushState({
weftView: 'explore',
exploreView: 'discover',
entry: 'direct-discover',
} satisfies ModalHistoryState, '');
}, []);

const closeExplore = useCallback((): void => {
const state = window.history.state as ModalHistoryState;
window.history.go(
state?.weftView === 'explore' &&
state.exploreView &&
state.entry !== 'direct-discover'
? -2
: -1,
);
}, []);

const handleVoiceModeChange = useCallback((channelId: string, active: boolean): void => {
void sessionRuntime.setVoiceMode(active, channelId);
}, []);
Expand Down Expand Up @@ -533,11 +562,11 @@ export default function App(): JSX.Element {
setAdding(true);
}}
onOpenExplore={openExplore}
onOpenDiscover={openDiscoverFromChat}
exploreOpen={exploreOpen}
onCloseExplore={() => {
const state = window.history.state as ModalHistoryState;
window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1);
}}
exploreInitialView={exploreEntry === 'direct-discover' ? 'discover' : undefined}
exploreDirectFromChat={exploreEntry === 'direct-discover'}
onCloseExplore={closeExplore}
onStartSession={() => openStart()}
onOpenDevices={openDevices}
devices={snapshot.devices}
Expand All @@ -557,8 +586,7 @@ export default function App(): JSX.Element {
}}
onGoHome={() => {
if (exploreOpen) {
const state = window.history.state as ModalHistoryState;
window.history.go(state?.weftView === 'explore' && state.exploreView ? -2 : -1);
closeExplore();
setExploreOpen(false);
}
setError(null);
Expand Down
30 changes: 29 additions & 1 deletion mobile/src/app/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,19 @@ vi.mock('@/ui/screens/LandingScreen', () => ({
vi.mock('@/ui/screens/SessionScreen', () => ({
SessionScreen: ({
onOpenExplore,
onOpenDiscover,
exploreOpen,
exploreInitialView,
exploreDirectFromChat,
onCloseExplore,
onGoHome,
}: {
onOpenExplore(): void;
onOpenDiscover(): void;
exploreOpen: boolean;
exploreInitialView?: string;
exploreDirectFromChat?: boolean;
onCloseExplore(): void;
onGoHome(): void;
}) => {
const [count, setCount] = useState(0);
Expand All @@ -102,10 +110,12 @@ vi.mock('@/ui/screens/SessionScreen', () => ({
<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>
<button type="button" onClick={onOpenDiscover}>Open Discover directly</button>
</main>
{exploreOpen ? (
<main data-testid="explore-screen">
Explore overlay
Explore overlay {exploreInitialView ?? 'home'} {exploreDirectFromChat ? 'from chat' : ''}
<button type="button" onClick={onCloseExplore}>Back to chat</button>
<button type="button" onClick={onGoHome}>Go Home</button>
</main>
) : null}
Expand Down Expand Up @@ -156,4 +166,22 @@ describe('App Explore layering', () => {

expect(go).toHaveBeenCalledWith(-2);
});

it('opens Discover directly with one history entry and returns to Chat in one Back action', async () => {
const go = vi.spyOn(window.history, 'go').mockImplementation(() => {});
render(<App />);
await screen.findByTestId('session-screen');

fireEvent.click(screen.getByRole('button', { name: 'Open Discover directly' }));

expect(await screen.findByTestId('explore-screen')).toHaveTextContent('discover from chat');
expect(window.history.state).toEqual({
weftView: 'explore',
exploreView: 'discover',
entry: 'direct-discover',
});

fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
expect(go).toHaveBeenCalledWith(-1);
});
});
Loading
Loading