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
33 changes: 27 additions & 6 deletions .github/workflows/companion-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,28 @@ jobs:
target: aarch64-apple-darwin
runs-on: ${{ matrix.platform }}
steps:
# pnpm-workspace.yaml globs '../packages/@nself/*', i.e. the
# nself-org/packages repo as a SIBLING directory. desktop/package.json
# depends on five of them as workspace:*, and tauri.conf.json's
# beforeBuildCommand runs `pnpm build` before cargo builds the shell.
# nclaw goes in a subdir so the sibling lands beside it, matching the
# layout the workspace file expects (same pattern as desktop-e2e.yml).
- uses: actions/checkout@v7
with:
path: nclaw
- uses: actions/checkout@v7
with:
repository: nself-org/packages
path: packages

- uses: actions/setup-node@v7
with: { node-version: '20' }
- uses: pnpm/action-setup@v6
with: { version: '9' }

- name: Install frontend dependencies
working-directory: nclaw
run: pnpm install --frozen-lockfile

- uses: dtolnay/rust-toolchain@stable

Expand All @@ -40,7 +61,7 @@ jobs:
patchelf

- name: Build Tauri app
working-directory: desktop/src-tauri
working-directory: nclaw/desktop/src-tauri
run: cargo tauri build

- name: Upload Linux artifacts
Expand All @@ -49,22 +70,22 @@ jobs:
with:
name: companion-${{ matrix.target }}
path: |
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
desktop/src-tauri/target/release/bundle/deb/*.deb
nclaw/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
nclaw/desktop/src-tauri/target/release/bundle/deb/*.deb

- name: Upload Windows artifacts
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v7
with:
name: companion-${{ matrix.target }}
path: |
desktop/src-tauri/target/release/bundle/msi/*.msi
desktop/src-tauri/target/release/bundle/nsis/*.exe
nclaw/desktop/src-tauri/target/release/bundle/msi/*.msi
nclaw/desktop/src-tauri/target/release/bundle/nsis/*.exe

- name: Upload macOS artifacts
if: matrix.platform == 'macos-latest'
uses: actions/upload-artifact@v7
with:
name: companion-${{ matrix.target }}
path: |
desktop/src-tauri/target/release/bundle/dmg/*.dmg
nclaw/desktop/src-tauri/target/release/bundle/dmg/*.dmg
5 changes: 5 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.0.0",
"@ai-sdk/react": "^3.0.0",
"@fontsource/inter": "^5.0.0",
"@formatjs/intl-localematcher": "^0.5.0",
"@sentry/react": "^8.0.0",
"@nself/auth-core": "workspace:*",
Expand Down Expand Up @@ -49,6 +50,7 @@
"devDependencies": {
"@formatjs/cli": "^6.0.0",
"@playwright/test": "^1.48",
"@tailwindcss/postcss": "^4.0.0",
"@tauri-apps/cli": "^2.3.0",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
Expand All @@ -57,8 +59,11 @@
"@types/react-syntax-highlighter": "^15.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.6",
"autoprefixer": "^10.4.0",
"jsdom": "^24.0.0",
"playwright": "^1.48",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.4.5",
"vite": "^6.4.2",
"vitest": "^3.2.6"
Expand Down
2 changes: 1 addition & 1 deletion desktop/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {}
}
}
26 changes: 25 additions & 1 deletion desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Purpose: ɳClaw desktop root component. Wraps the app with shared providers and
* renders the main shell. Auth state drives the top-level view.
* Inputs: Auth context from NselfAuthProvider (via useAuth hook).
* Outputs: Renders i18n-wrapped shell. Unauthenticated state shows a sign-in prompt.
* Outputs: Renders i18n-wrapped shell. Authenticated state renders the chat UI;
* unauthenticated state shows a sign-in prompt.
* Constraints:
* - NselfI18nProvider wraps all children so useNselfTranslation() works everywhere.
* - Do NOT lift graphql queries here — let page components own their queries.
Expand All @@ -15,6 +16,7 @@ import * as Sentry from '@sentry/react';
import { NselfI18nProvider, isRTL, useNselfTranslation, useTranslation } from '@nself/i18n';
import { useAuth } from '@nself/auth-core';
import { initObservability } from '@nself/observability';
import { ChatContainer } from './components/chat/ChatContainer';

// Initialize Sentry error reporting (runs at module load, before first render)
// Vite exposes build-time env via import.meta.env, not the Node-only `process`
Expand Down Expand Up @@ -59,6 +61,28 @@ function Shell(): React.ReactElement {
const { t } = useNselfTranslation();
useDocumentDir();

// Authenticated is the only state with content below the title bar — render
// it full-height so ChatContainer's own flex layout can fill the window.
if (status === 'authenticated') {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
fontFamily: 'system-ui, sans-serif',
}}
>
<div style={{ padding: '0.5rem 1rem', color: '#6b7280', fontSize: '0.75rem' }}>
{t('desktop.nclaw.title')}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<ChatContainer />
</div>
</div>
);
}

return (
<div
style={{
Expand Down
36 changes: 29 additions & 7 deletions desktop/src/lib/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,46 @@ interface ChatMessage {
/**
* Bridges Vercel AI SDK transport to the Tauri `stream_chat` command.
* Real streaming lands in S15.T17 when LlmBackend is wired. For now this
* calls the stub command and wraps the reply in a single SSE chunk.
* calls the stub command and wraps the reply as a single-chunk UI Message
* Stream (the protocol @ai-sdk/react's useChat/DefaultChatTransport parses
* as of AI SDK v6 — see `ai`'s src/ui-message-stream/ui-message-chunks.ts).
* A prior version emitted an ad hoc `{"content": ...}` SSE payload left over
* from an older SDK version; DefaultChatTransport's strict chunk schema
* rejects unrecognized shapes, so no message ever reached the UI.
*
* DefaultChatTransport calls its fetch override as fetch(url, init) — the
* standard two-argument form, not fetch(Request) — so this must accept both
* to satisfy the `typeof fetch` cast in ChatContainer.tsx. A prior version
* only accepted `Request` and called req.json(), which throws immediately
* since `input` here is the API url string, not a Request.
*/
export async function chatTransport(req: Request): Promise<Response> {
const body = await req.json() as { messages: ChatMessage[] };
export async function chatTransport(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const bodyText = input instanceof Request ? await input.text() : ((init?.body as string) ?? '{}');
const body = JSON.parse(bodyText) as { messages: ChatMessage[] };
const reply = await invoke<string>('stream_chat', { messages: body.messages });

const encoder = new TextEncoder();
const messageId = crypto.randomUUID();
const chunk = (part: Record<string, unknown>) =>
encoder.encode(`data: ${JSON.stringify(part)}\n\n`);

const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: reply })}\n\n`)
);
controller.enqueue(chunk({ type: 'text-start', id: messageId }));
controller.enqueue(chunk({ type: 'text-delta', id: messageId, delta: reply }));
controller.enqueue(chunk({ type: 'text-end', id: messageId }));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});

return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
headers: {
'Content-Type': 'text/event-stream',
'x-vercel-ai-ui-message-stream': 'v1',
},
});
}
8 changes: 8 additions & 0 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
* Inputs: VITE_NSELF_SENTRY_DSN, VITE_APP_ENV, VITE_APP_VERSION from build env.
* Outputs: React app mounted on #root, Sentry + OTel registered, i18next initialised.
* Constraints:
* - styles/tokens.css and styles/globals.css (which pulls in Tailwind via
* @import 'tailwindcss') MUST be imported here — Vite only emits CSS that's reachable
* from the module graph. Without this import every Tailwind utility class
* in the tree is inert (elements keep className but get no rules), which
* silently collapses flex layouts (e.g. ChatList's scroll container) to
* zero height.
* - initObservability() MUST be called before ReactDOM.createRoot() so Sentry catches
* any errors thrown during React hydration.
* - initializeI18next() runs synchronously at module level — must complete before render.
Expand All @@ -24,6 +30,8 @@ import { Provider as UrqlProvider } from 'urql';
import App from './App';
import { authStrategy } from './lib/auth';
import { graphqlClient } from './lib/graphql';
import './styles/tokens.css';
import './styles/globals.css';

// ─── i18n init (module level — before first render) ──────────────────────────
initializeI18next();
Expand Down
8 changes: 5 additions & 3 deletions desktop/src/styles/globals.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
@import '@fontsource/inter';
@import 'tailwindcss';

@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind v4 is CSS-first, but tailwind.config.js still carries this app's
custom colors (bg-surface, accent) and darkMode setting — @config keeps
that file authoritative instead of duplicating theme values here. */
@config '../../tailwind.config.js';

body {
@apply bg-surface text-slate-100 font-sans antialiased;
Expand Down
35 changes: 33 additions & 2 deletions desktop/tests/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedSession } from './fixtures/auth';

// Both tests require an authenticated session: App.tsx only mounts
// ChatContainer (the composer + message list) once useAuth() resolves to
// 'authenticated' (see desktop/src/App.tsx Shell()). A fresh, unauthenticated
// page has no chat UI at all, so every test here seeds a session first.

test('user can send a message and see a stub reply', async ({ page }) => {
await mockAuthenticatedSession(page);
await page.goto('/');
const input = page.getByPlaceholder(/Type a message/i);

// Real placeholder string is @nself/i18n's desktop.nclaw.messagePlaceholder
// ("Message ɳClaw…"), wired in InputArea.tsx via useNselfTranslation().
const input = page.getByPlaceholder(/Message ɳClaw/i);
await input.waitFor({ timeout: 5000 });
await input.fill('Hello');
await page.keyboard.press('Enter');

// stream_chat's real Rust command still returns NotImplemented pending
// S15.T17 (desktop/src-tauri/src/commands/chat.rs); the mocked Tauri
// bridge in fixtures/auth.ts stands in for it with the same placeholder
// text so this test exercises the real send -> render pipeline (useChat,
// ChatContainer, ChatList, MessageBubble) without depending on a native
// Tauri host, which Playwright cannot drive.
await expect(page.locator('text=(stub response)')).toBeVisible({ timeout: 10000 });
});

test('markdown renders in messages', async ({ page }) => {
await mockAuthenticatedSession(page);
await page.goto('/');
await expect(page.locator('article, [role="article"], .markdown, .prose').first()).toBeAttached({ timeout: 10000 });

const input = page.getByPlaceholder(/Message ɳClaw/i);
await input.waitFor({ timeout: 5000 });
await input.fill('**bold text**');
await page.keyboard.press('Enter');

// MessageBubble renders message content through react-markdown
// (remark-gfm), which turns **bold text** into a real <strong> element.
// This fails again if MessageBubble stops rendering markdown (e.g. reverts
// to a plain text node), unlike a generic container-class selector that
// could match unrelated markup.
await expect(page.locator('strong', { hasText: 'bold text' })).toBeAttached({
timeout: 10000,
});
});
97 changes: 97 additions & 0 deletions desktop/tests/e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Purpose: Seed an authenticated session for nclaw desktop E2E tests.
* Inputs: Playwright Page.
* Outputs: Injects a TokenPair into localStorage (the same keys
* NativeAuthStrategy/SecureStoreInterface read from — see
* desktop/src/lib/auth.ts and @nself/auth-core/src/native.helpers.ts
* SECURE_STORE_KEYS) before the page's own scripts run, and stubs
* window.__TAURI_INTERNALS__ so invoke() resolves without a real
* Tauri binary (Playwright drives a plain Chromium window, which
* has no Tauri IPC bridge at all).
* Constraints:
* - Must run via page.addInitScript, before React mounts and before
* NativeAuthStrategy.init() reads SecureStore, or the app renders
* 'loading' -> 'unauthenticated' and the chat UI never appears.
* - expiresAt is set far in the future so the proactive refresh loop
* (DEFAULT_REFRESH_BUFFER_MS before expiry) never fires mid-test and
* tries to hit a real auth server that doesn't exist in CI.
* - The JWT signature is not verified client-side (decodeUserFromJwt only
* reads the payload), so any syntactically valid three-part token works.
*/

import type { Page } from '@playwright/test';

// Mirrors @nself/auth-core SECURE_STORE_KEYS (native.helpers.ts) — desktop's
// localSecureStore (desktop/src/lib/auth.ts) is a thin localStorage wrapper
// around the same keys.
const SECURE_STORE_KEYS = {
ACCESS_TOKEN: '@nself/auth-core/accessToken',
REFRESH_TOKEN: '@nself/auth-core/refreshToken',
EXPIRES_AT: '@nself/auth-core/expiresAt',
} as const;

/**
* Seed a valid, far-from-expiry TokenPair and stub the Tauri IPC bridge so
* the app boots into the 'authenticated' state and can call stream_chat
* without a native Tauri host. Call before page.goto('/').
*/
export async function mockAuthenticatedSession(page: Page): Promise<void> {
await page.addInitScript(
({ accessKey, refreshKey, expiresKey }) => {
function base64url(obj: Record<string, unknown>): string {
return btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

const header = base64url({ alg: 'HS256', typ: 'JWT' });
const payload = base64url({
sub: 'e2e-test-user-id',
email: 'e2e@example.test',
display_name: 'E2E Test User',
'https://hasura.io/jwt/claims': {
'x-hasura-allowed-roles': ['user'],
'x-hasura-default-role': 'user',
},
});
const fakeJwt = `${header}.${payload}.e2e-test-signature`;

window.localStorage.setItem(accessKey, fakeJwt);
window.localStorage.setItem(refreshKey, 'e2e-test-refresh-token');
// 24h out — well past DEFAULT_REFRESH_BUFFER_MS (60s), so the refresh
// loop schedules for tomorrow and never fires during the test run.
window.localStorage.setItem(expiresKey, String(Date.now() + 24 * 60 * 60 * 1000));

// Stub the Tauri IPC bridge. Real command implementations are Rust
// (desktop/src-tauri/src/commands/*.rs); Playwright drives a plain
// browser window with no native host, so invoke() would otherwise
// throw "window.__TAURI_INTERNALS__ is undefined" before React can
// even render the composer.
const w = window as unknown as Record<string, unknown>;
w.__TAURI_INTERNALS__ = {
transformCallback: (callback: (value: unknown) => void): number => {
const id = Math.floor(Math.random() * 1e9);
(w as Record<string, unknown>)[`_tauriCb_${id}`] = callback;
return id;
},
invoke: async (cmd: string): Promise<unknown> => {
if (cmd === 'stream_chat') {
// stream_chat's real Rust implementation is a stub returning
// NotImplemented (see desktop/src-tauri/src/commands/chat.rs,
// awaiting S15.T17). Mirror that shape here rather than a
// hand-picked success value, so this fixture does not claim
// more of the backend works than actually does.
return '(stub response)';
}
return null;
},
};
},
{
accessKey: SECURE_STORE_KEYS.ACCESS_TOKEN,
refreshKey: SECURE_STORE_KEYS.REFRESH_TOKEN,
expiresKey: SECURE_STORE_KEYS.EXPIRES_AT,
},
);
}
Loading
Loading