feat: add companion API and Android APK shell
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
|
||||
import { CompanionClient, loadCompanionSettings, saveCompanionSettings, type CompanionSettings } from './companionClient.js';
|
||||
|
||||
type CompanionContextValue = {
|
||||
settings: CompanionSettings;
|
||||
client: CompanionClient;
|
||||
updateSettings: (settings: CompanionSettings) => void;
|
||||
};
|
||||
|
||||
const CompanionContext = createContext<CompanionContextValue | undefined>(undefined);
|
||||
|
||||
export function CompanionProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState(loadCompanionSettings);
|
||||
const client = useMemo(() => new CompanionClient(settings), [settings]);
|
||||
|
||||
function updateSettings(nextSettings: CompanionSettings): void {
|
||||
const normalizedSettings = {
|
||||
baseUrl: nextSettings.baseUrl.trim(),
|
||||
accessKey: nextSettings.accessKey.trim(),
|
||||
};
|
||||
|
||||
saveCompanionSettings(normalizedSettings);
|
||||
setSettings(normalizedSettings);
|
||||
}
|
||||
|
||||
return <CompanionContext.Provider value={{ settings, client, updateSettings }}>{children}</CompanionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCompanion(): CompanionContextValue {
|
||||
const value = useContext(CompanionContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error('useCompanion must be used inside CompanionProvider');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
AuthValidateResponse,
|
||||
ChatResponse,
|
||||
FileListResponse,
|
||||
FileMetadataResponse,
|
||||
FileReadResponse,
|
||||
FileWriteResponse,
|
||||
HealthResponse,
|
||||
TerminalRunResponse,
|
||||
} from '@hermes-mobile/shared';
|
||||
|
||||
export const DEFAULT_COMPANION_URL = 'http://10.0.2.2:8787';
|
||||
export const COMPANION_URL_STORAGE_KEY = 'hermes-mobile.companionUrl';
|
||||
export const ACCESS_KEY_STORAGE_KEY = 'hermes-mobile.accessKey';
|
||||
|
||||
export type CompanionSettings = {
|
||||
baseUrl: string;
|
||||
accessKey: string;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function loadCompanionSettings(): CompanionSettings {
|
||||
return {
|
||||
baseUrl: localStorage.getItem(COMPANION_URL_STORAGE_KEY) || DEFAULT_COMPANION_URL,
|
||||
accessKey: localStorage.getItem(ACCESS_KEY_STORAGE_KEY) || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function saveCompanionSettings(settings: CompanionSettings): void {
|
||||
localStorage.setItem(COMPANION_URL_STORAGE_KEY, trimTrailingSlash(settings.baseUrl || DEFAULT_COMPANION_URL));
|
||||
localStorage.setItem(ACCESS_KEY_STORAGE_KEY, settings.accessKey);
|
||||
}
|
||||
|
||||
export class CompanionClient {
|
||||
constructor(private readonly settings: CompanionSettings) {}
|
||||
|
||||
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const url = new URL(`${trimTrailingSlash(this.settings.baseUrl)}${path}`);
|
||||
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(this.settings.accessKey ? { Authorization: `Bearer ${this.settings.accessKey}` } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (options.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
|
||||
try {
|
||||
payload = text ? (JSON.parse(text) as unknown) : undefined;
|
||||
} catch {
|
||||
payload = { error: text };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof payload === 'object' && payload !== null && 'error' in payload ? String(payload.error) : response.statusText;
|
||||
throw new Error(message || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
health(): Promise<HealthResponse> {
|
||||
return this.request<HealthResponse>('/api/health');
|
||||
}
|
||||
|
||||
validate(): Promise<AuthValidateResponse> {
|
||||
return this.request<AuthValidateResponse>('/api/auth/validate');
|
||||
}
|
||||
|
||||
chat(prompt: string): Promise<ChatResponse> {
|
||||
return this.request<ChatResponse>('/api/chat', { method: 'POST', body: { prompt } });
|
||||
}
|
||||
|
||||
listFiles(path: string): Promise<FileListResponse> {
|
||||
return this.request<FileListResponse>('/api/files', { query: { path } });
|
||||
}
|
||||
|
||||
readFile(path: string): Promise<FileReadResponse> {
|
||||
return this.request<FileReadResponse>('/api/files/read', { query: { path } });
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string): Promise<FileWriteResponse> {
|
||||
return this.request<FileWriteResponse>('/api/files/write', { method: 'POST', body: { path, content } });
|
||||
}
|
||||
|
||||
metadata(path: string): Promise<FileMetadataResponse> {
|
||||
return this.request<FileMetadataResponse>('/api/files/metadata', { query: { path } });
|
||||
}
|
||||
|
||||
runCommand(command: string, cwd: string, timeoutMs = 10000): Promise<TerminalRunResponse> {
|
||||
return this.request<TerminalRunResponse>('/api/terminal/run', { method: 'POST', body: { command, cwd, timeoutMs } });
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -1,22 +1,22 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { AppShell } from '../components/app-shell/AppShell.js';
|
||||
import { ActivityScreen } from '../screens/ActivityScreen/ActivityScreen.js';
|
||||
import { AskScreen } from '../screens/AskScreen/AskScreen.js';
|
||||
import { CronScreen } from '../screens/CronScreen/CronScreen.js';
|
||||
import { ChatScreen } from '../screens/AskScreen/ChatScreen.js';
|
||||
import { FilesScreen } from '../screens/FilesScreen/FilesScreen.js';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen/SettingsScreen.js';
|
||||
import { TerminalScreen } from '../screens/TerminalScreen/TerminalScreen.js';
|
||||
import type { ScreenId } from './navigation.js';
|
||||
|
||||
const screens: Record<ScreenId, ReactNode> = {
|
||||
ask: <AskScreen />,
|
||||
activity: <ActivityScreen />,
|
||||
cron: <CronScreen />,
|
||||
files: <FilesScreen />,
|
||||
settings: <SettingsScreen />,
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>('ask');
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>('chat');
|
||||
|
||||
const screens: Record<ScreenId, ReactNode> = {
|
||||
chat: <ChatScreen />,
|
||||
files: <FilesScreen />,
|
||||
terminal: <TerminalScreen />,
|
||||
activity: <ActivityScreen />,
|
||||
settings: <SettingsScreen />,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell activeScreen={activeScreen} onNavigate={setActiveScreen}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Activity, CalendarClock, Files, MessageCircle, Settings } from 'lucide-react';
|
||||
import { Activity, Files, MessageCircle, Settings, TerminalSquare } from 'lucide-react';
|
||||
|
||||
export const navItems = [
|
||||
{ id: 'ask', label: 'Ask', icon: MessageCircle },
|
||||
{ id: 'activity', label: 'Activity', icon: Activity },
|
||||
{ id: 'cron', label: 'Cron', icon: CalendarClock },
|
||||
{ id: 'chat', label: 'Chat', icon: MessageCircle },
|
||||
{ id: 'files', label: 'Files', icon: Files },
|
||||
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
|
||||
{ id: 'activity', label: 'Status', icon: Activity },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
import type { ScreenId } from '../../app/navigation.js';
|
||||
import { BottomNav } from '../bottom-nav/BottomNav.js';
|
||||
|
||||
@@ -9,6 +10,26 @@ type AppShellProps = {
|
||||
};
|
||||
|
||||
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
|
||||
const { client } = useCompanion();
|
||||
const [online, setOnline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
client
|
||||
.health()
|
||||
.then(() => {
|
||||
if (!cancelled) setOnline(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOnline(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
@@ -16,9 +37,9 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)
|
||||
<p className="eyebrow">Hermes Mobile</p>
|
||||
<h1>Agent command center</h1>
|
||||
</div>
|
||||
<div className="status-pill">
|
||||
<div className={`status-pill ${online ? 'status-pill--online' : ''}`}>
|
||||
<span className="status-pill__dot" />
|
||||
Companion pending
|
||||
{online ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">{children}</main>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className="bottom-nav__item"
|
||||
className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`}
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { CompanionProvider } from './api/CompanionContext.js';
|
||||
import { App } from './app/App.js';
|
||||
import './styles/globals.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<CompanionProvider>
|
||||
<App />
|
||||
</CompanionProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
const events = [
|
||||
['task.created', 'Task lifecycle events will appear here.'],
|
||||
['tool_call.started', 'Tool cards will expand with raw output.'],
|
||||
['assistant.final', 'Final answers stay attached to sessions.'],
|
||||
] as const;
|
||||
import { useState } from 'react';
|
||||
import type { HealthResponse } from '@hermes-mobile/shared';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
export function ActivityScreen() {
|
||||
const { client, settings } = useCompanion();
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [status, setStatus] = useState('Tap refresh to check companion status.');
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setStatus('Checking companion…');
|
||||
|
||||
try {
|
||||
const response = await client.health();
|
||||
setHealth(response);
|
||||
setStatus('Companion is reachable.');
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Health check failed');
|
||||
setHealth(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Activity timeline</h2>
|
||||
<div className="timeline-list">
|
||||
{events.map(([type, description]) => (
|
||||
<article className="timeline-card" key={type}>
|
||||
<span>{type}</span>
|
||||
<p>{description}</p>
|
||||
</article>
|
||||
))}
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Activity</p>
|
||||
<h2>Status</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void refresh()} type="button">Refresh</button>
|
||||
</div>
|
||||
<article className="panel-card">
|
||||
<dl className="status-grid">
|
||||
<dt>Companion URL</dt><dd>{settings.baseUrl}</dd>
|
||||
<dt>Status</dt><dd>{status}</dd>
|
||||
<dt>Hermes CLI</dt><dd>{health?.checks.hermes.cliAvailable ? `Found at ${health.checks.hermes.cliPath}` : 'Unknown / unavailable'}</dd>
|
||||
<dt>Workspace</dt><dd>{health?.config.workspaceRoot ?? 'Unknown'}</dd>
|
||||
<dt>Uptime</dt><dd>{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Mic, Paperclip, SendHorizontal } from 'lucide-react';
|
||||
|
||||
export function AskScreen() {
|
||||
return (
|
||||
<section className="screen ask-screen">
|
||||
<div className="hero-card">
|
||||
<p className="eyebrow">Quick ask</p>
|
||||
<h2>Send Hermes a task from your phone.</h2>
|
||||
<p>Text prompting lands first; voice, uploads, streaming activity, and approvals build on this shell.</p>
|
||||
</div>
|
||||
|
||||
<form className="composer" onSubmit={(event) => event.preventDefault()}>
|
||||
<textarea aria-label="Prompt" placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} />
|
||||
<div className="composer__actions">
|
||||
<button className="icon-button" type="button" aria-label="Attach file">
|
||||
<Paperclip size={20} />
|
||||
</button>
|
||||
<button className="icon-button" type="button" aria-label="Record voice note">
|
||||
<Mic size={20} />
|
||||
</button>
|
||||
<button className="send-button" type="submit">
|
||||
Send <SendHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { SendHorizontal } from 'lucide-react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function ChatScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submitPrompt(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
|
||||
if (!prompt.trim() || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrompt = prompt.trim();
|
||||
setPrompt('');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessages((currentMessages) => [...currentMessages, { role: 'user', content: nextPrompt }]);
|
||||
|
||||
try {
|
||||
const response = await client.chat(nextPrompt);
|
||||
setMessages((currentMessages) => [...currentMessages, { role: 'assistant', content: response.reply }]);
|
||||
if (response.stderr) {
|
||||
setError(response.stderr);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Chat request failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen chat-screen">
|
||||
<div className="hero-card">
|
||||
<p className="eyebrow">Chat</p>
|
||||
<h2>Send Hermes a task from your phone.</h2>
|
||||
<p>Prompts are sent to the companion server, which invokes the local Hermes CLI when available.</p>
|
||||
</div>
|
||||
|
||||
<div className="message-list" aria-live="polite">
|
||||
{messages.length === 0 ? <p className="empty-note">No messages yet. Ask Hermes to inspect, edit, or summarize something.</p> : null}
|
||||
{messages.map((message, index) => (
|
||||
<article className={`message message--${message.role}`} key={`${message.role}-${index}`}>
|
||||
<span>{message.role}</span>
|
||||
<p>{message.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="error-card">{error}</p> : null}
|
||||
|
||||
<form className="composer" onSubmit={submitPrompt}>
|
||||
<textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} value={prompt} />
|
||||
<div className="composer__actions">
|
||||
<button className="send-button" disabled={busy || !prompt.trim()} type="submit">
|
||||
{busy ? 'Sending…' : 'Send'} <SendHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
function parentPath(path: string): string {
|
||||
if (path === '.') return '.';
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? parts.join('/') : '.';
|
||||
}
|
||||
|
||||
export function FilesScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [path, setPath] = useState('.');
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [activeFile, setActiveFile] = useState<FileReadResponse | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function loadDirectory(nextPath = path): Promise<void> {
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await client.listFiles(nextPath);
|
||||
setPath(response.path);
|
||||
setEntries(response.entries);
|
||||
setActiveFile(null);
|
||||
setDraft('');
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to list files');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openEntry(entry: FileEntry): Promise<void> {
|
||||
if (entry.type === 'directory') {
|
||||
await loadDirectory(entry.path);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
const response = await client.readFile(entry.path);
|
||||
setActiveFile(response);
|
||||
setDraft(response.content);
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to read file');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(): Promise<void> {
|
||||
if (!activeFile) return;
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await client.writeFile(activeFile.path, draft);
|
||||
setStatus(`Saved ${response.bytesWritten} bytes to ${response.path}`);
|
||||
await openEntry({ name: activeFile.path, path: activeFile.path, type: 'file', size: 0, modifiedAt: new Date().toISOString() });
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to save file');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectory('.');
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Files</h2>
|
||||
<article className="empty-card">
|
||||
<p>Uploads and generated artifacts will live here for reuse, download, and sharing.</p>
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Workspace</p>
|
||||
<h2>Files</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
<article className="panel-card">
|
||||
<div className="path-bar">
|
||||
<button disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button">Up</button>
|
||||
<span>{path}</span>
|
||||
</div>
|
||||
<div className="file-list">
|
||||
{entries.map((entry) => (
|
||||
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button">
|
||||
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span>
|
||||
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{activeFile ? (
|
||||
<article className="panel-card editor-card">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Editing</p>
|
||||
<h3>{activeFile.path}</h3>
|
||||
</div>
|
||||
<button className="small-button" disabled={busy} onClick={() => void saveFile()} type="button">Save</button>
|
||||
</div>
|
||||
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{status ? <p className="error-card">{status}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { client, settings, updateSettings } = useCompanion();
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl);
|
||||
const [accessKey, setAccessKey] = useState(settings.accessKey);
|
||||
const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.');
|
||||
|
||||
function save(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
setStatus('Settings saved.');
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
setStatus('Testing connection…');
|
||||
|
||||
try {
|
||||
const testClient = new CompanionClient({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
|
||||
await testClient.health();
|
||||
const response = await testClient.validate();
|
||||
setStatus(response.message);
|
||||
} catch (caughtError) {
|
||||
setStatus(caughtError instanceof Error ? caughtError.message : 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<h2>Settings</h2>
|
||||
<article className="settings-card">
|
||||
<form className="settings-card" onSubmit={save}>
|
||||
<label htmlFor="server-url">Companion server URL</label>
|
||||
<input id="server-url" placeholder="https://hermes.local:8787" type="url" />
|
||||
<p>Pairing, health checks, notification setup, and theme controls will expand from here.</p>
|
||||
</article>
|
||||
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} />
|
||||
<label htmlFor="access-key">Access key</label>
|
||||
<input id="access-key" onChange={(event) => setAccessKey(event.target.value)} placeholder="hm_…" type="password" value={accessKey} />
|
||||
<div className="composer__actions">
|
||||
<button className="small-button" type="submit">Save</button>
|
||||
<button className="send-button" onClick={() => void testConnection()} type="button">Test connection</button>
|
||||
</div>
|
||||
<p>{status}</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useCompanion } from '../../api/CompanionContext.js';
|
||||
|
||||
export function TerminalScreen() {
|
||||
const { client } = useCompanion();
|
||||
const [cwd, setCwd] = useState('.');
|
||||
const [command, setCommand] = useState('pwd && ls');
|
||||
const [output, setOutput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function runCommand(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!command.trim() || busy) return;
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await client.runCommand(command, cwd);
|
||||
setCwd(response.cwd);
|
||||
setOutput([
|
||||
`$ ${response.command}`,
|
||||
response.stdout,
|
||||
response.stderr ? `stderr:\n${response.stderr}` : '',
|
||||
`exitCode=${response.exitCode ?? 'null'} timedOut=${response.timedOut}`,
|
||||
].filter(Boolean).join('\n'));
|
||||
} catch (caughtError) {
|
||||
setOutput(caughtError instanceof Error ? caughtError.message : 'Command failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screen stack-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Remote shell</p>
|
||||
<h2>Terminal</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="panel-card terminal-form" onSubmit={runCommand}>
|
||||
<label htmlFor="cwd">Working directory</label>
|
||||
<input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} />
|
||||
<label htmlFor="command">Command</label>
|
||||
<textarea id="command" onChange={(event) => setCommand(event.target.value)} rows={4} value={command} />
|
||||
<button className="send-button" disabled={busy || !command.trim()} type="submit">{busy ? 'Running…' : 'Run command'}</button>
|
||||
</form>
|
||||
<pre className="terminal-output">{output || 'Command output appears here.'}</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,7 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
@@ -22,44 +20,32 @@ body {
|
||||
#11131f;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
width: min(100%, 32rem);
|
||||
width: min(100%, 34rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.app-header {
|
||||
.app-header, .screen-heading, .composer__actions, .path-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0 1rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-header h1,
|
||||
.screen h2,
|
||||
.hero-card h2 {
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.app-header, .screen-heading, .path-bar { justify-content: space-between; }
|
||||
.app-header { align-items: flex-start; padding: 0.75rem 0 1rem; }
|
||||
|
||||
.app-header h1 {
|
||||
max-width: 12rem;
|
||||
font-size: clamp(1.5rem, 7vw, 2.35rem);
|
||||
}
|
||||
.app-header h1, .screen h2, .hero-card h2, .editor-card h3 { margin: 0; line-height: 1.05; }
|
||||
.app-header h1 { max-width: 12rem; font-size: clamp(1.5rem, 7vw, 2.35rem); }
|
||||
.screen h2 { font-size: 2rem; }
|
||||
.editor-card h3 { max-width: 14rem; overflow-wrap: anywhere; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.4rem;
|
||||
@@ -88,25 +74,19 @@ button {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #ffca6a;
|
||||
box-shadow: 0 0 1rem rgba(255, 202, 106, 0.9);
|
||||
background: #ff6a6a;
|
||||
box-shadow: 0 0 1rem rgba(255, 106, 106, 0.9);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0 1rem;
|
||||
.status-pill--online .status-pill__dot {
|
||||
background: #7cffb1;
|
||||
box-shadow: 0 0 1rem rgba(124, 255, 177, 0.9);
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.app-main { overflow: auto; padding: 0.5rem 0 1rem; }
|
||||
.screen { display: grid; gap: 1rem; }
|
||||
|
||||
.hero-card,
|
||||
.composer,
|
||||
.timeline-card,
|
||||
.empty-card,
|
||||
.settings-card {
|
||||
.hero-card, .composer, .panel-card, .message, .error-card, .settings-card, .terminal-output {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(21, 24, 39, 0.78);
|
||||
@@ -114,116 +94,63 @@ button {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.hero-card, .panel-card, .settings-card, .error-card { padding: 1rem; }
|
||||
.hero-card h2 { font-size: clamp(2rem, 11vw, 3.65rem); letter-spacing: -0.06em; }
|
||||
.hero-card p:last-child, .panel-card p, .settings-card p, .empty-note { margin: 0.8rem 0 0; color: rgba(255, 248, 236, 0.72); line-height: 1.5; }
|
||||
|
||||
.hero-card h2 {
|
||||
font-size: clamp(2rem, 11vw, 3.65rem);
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.hero-card p:last-child,
|
||||
.timeline-card p,
|
||||
.empty-card p,
|
||||
.settings-card p {
|
||||
margin: 0.8rem 0 0;
|
||||
color: rgba(255, 248, 236, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.composer textarea,
|
||||
.settings-card input {
|
||||
.composer, .terminal-form, .settings-card { display: grid; gap: 0.85rem; padding: 0.75rem; }
|
||||
.composer textarea, .settings-card input, .terminal-form input, .terminal-form textarea, .editor-card textarea {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
outline: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff8ec;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 9rem;
|
||||
resize: vertical;
|
||||
padding: 1rem;
|
||||
}
|
||||
.composer textarea { min-height: 9rem; resize: vertical; }
|
||||
.editor-card textarea { min-height: 18rem; resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.85rem; }
|
||||
textarea::placeholder, input::placeholder { color: rgba(255, 248, 236, 0.42); }
|
||||
|
||||
.composer textarea::placeholder,
|
||||
.settings-card input::placeholder {
|
||||
color: rgba(255, 248, 236, 0.42);
|
||||
}
|
||||
|
||||
.composer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.send-button {
|
||||
.send-button, .small-button, .path-bar button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
min-height: 2.7rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 3rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff8ec;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
padding: 0 1.05rem;
|
||||
background: #ffca6a;
|
||||
color: #17110a;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stack-screen h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.send-button { gap: 0.45rem; margin-left: auto; padding: 0 1.05rem; background: #ffca6a; color: #17110a; }
|
||||
.small-button, .path-bar button { padding: 0 0.9rem; background: rgba(255, 255, 255, 0.1); color: #fff8ec; }
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
.message-list, .file-list { display: grid; gap: 0.65rem; }
|
||||
.message { padding: 0.85rem; }
|
||||
.message span, .status-grid dt, .terminal-form label { color: #89e5ff; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
|
||||
.message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.45; }
|
||||
.message--user { background: rgba(255, 202, 106, 0.14); }
|
||||
.error-card { color: #ffd0d0; }
|
||||
|
||||
.path-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: rgba(255, 248, 236, 0.72); }
|
||||
.file-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
padding: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #fff8ec;
|
||||
text-align: left;
|
||||
}
|
||||
.file-row small { color: rgba(255, 248, 236, 0.58); }
|
||||
|
||||
.timeline-card,
|
||||
.empty-card,
|
||||
.settings-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.timeline-card span {
|
||||
color: #89e5ff;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-card label {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-card input {
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; margin: 0; }
|
||||
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: rgba(255, 248, 236, 0.82); }
|
||||
.terminal-output { min-height: 12rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #d7f8ff; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.82rem; }
|
||||
|
||||
.bottom-nav {
|
||||
display: grid;
|
||||
@@ -250,7 +177,4 @@ button {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-nav__item[aria-current='page'] {
|
||||
background: rgba(255, 202, 106, 0.16);
|
||||
color: #ffca6a;
|
||||
}
|
||||
.bottom-nav__item--active, .bottom-nav__item[aria-current='page'] { background: rgba(255, 202, 106, 0.16); color: #ffca6a; }
|
||||
|
||||
Reference in New Issue
Block a user