feat: add phase 1 runnable skeleton

This commit is contained in:
Hermes Agent
2026-07-09 04:19:23 +00:00
parent deec0c7fa3
commit bd0cf34aa3
35 changed files with 675 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#11131f" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Hermes Mobile</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"name":"@hermes-mobile/mobile","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -p tsconfig.json && vite build","preview":"vite preview --host 0.0.0.0","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@hermes-mobile/shared":"workspace:*","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.2","@types/react-dom":"^19.0.2","@vitejs/plugin-react":"^4.3.4","vite":"^6.0.7"}}
+1
View File
@@ -0,0 +1 @@
{"name":"Hermes Mobile","short_name":"Hermes","description":"Mobile control surface for Hermes Agent","start_url":"/","display":"standalone","background_color":"#11131f","theme_color":"#11131f","icons":[]}
+26
View File
@@ -0,0 +1,26 @@
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 { FilesScreen } from '../screens/FilesScreen/FilesScreen.js';
import { SettingsScreen } from '../screens/SettingsScreen/SettingsScreen.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');
return (
<AppShell activeScreen={activeScreen} onNavigate={setActiveScreen}>
{screens[activeScreen]}
</AppShell>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { Activity, CalendarClock, Files, MessageCircle, Settings } 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: 'files', label: 'Files', icon: Files },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const;
export type ScreenId = (typeof navItems)[number]['id'];
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import type { ScreenId } from '../../app/navigation.js';
import { BottomNav } from '../bottom-nav/BottomNav.js';
type AppShellProps = {
activeScreen: ScreenId;
children: ReactNode;
onNavigate: (screen: ScreenId) => void;
};
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Hermes Mobile</p>
<h1>Agent command center</h1>
</div>
<div className="status-pill">
<span className="status-pill__dot" />
Companion pending
</div>
</header>
<main className="app-main">{children}</main>
<BottomNav activeScreen={activeScreen} onNavigate={onNavigate} />
</div>
);
}
@@ -0,0 +1,31 @@
import type { ScreenId } from '../../app/navigation.js';
import { navItems } from '../../app/navigation.js';
type BottomNavProps = {
activeScreen: ScreenId;
onNavigate: (screen: ScreenId) => void;
};
export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.id === activeScreen;
return (
<button
aria-current={active ? 'page' : undefined}
className="bottom-nav__item"
key={item.id}
onClick={() => onNavigate(item.id)}
type="button"
>
<Icon aria-hidden="true" size={20} strokeWidth={2.4} />
<span>{item.label}</span>
</button>
);
})}
</nav>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App.js';
import './styles/globals.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,21 @@
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;
export function ActivityScreen() {
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>
</section>
);
}
@@ -0,0 +1,28 @@
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,10 @@
export function CronScreen() {
return (
<section className="screen stack-screen">
<h2>Cron</h2>
<article className="empty-card">
<p>Hermes cron jobs will be listed here with run, pause, resume, and output controls.</p>
</article>
</section>
);
}
@@ -0,0 +1,10 @@
export function FilesScreen() {
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>
</article>
</section>
);
}
@@ -0,0 +1,12 @@
export function SettingsScreen() {
return (
<section className="screen stack-screen">
<h2>Settings</h2>
<article className="settings-card">
<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>
</section>
);
}
+256
View File
@@ -0,0 +1,256 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #11131f;
color: #fff8ec;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 20% 10%, rgba(255, 184, 77, 0.24), transparent 34rem),
radial-gradient(circle at 80% 0%, rgba(102, 92, 255, 0.24), transparent 28rem),
#11131f;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 32rem);
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 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0 1rem;
}
.app-header h1,
.screen h2,
.hero-card h2 {
margin: 0;
line-height: 1.05;
}
.app-header h1 {
max-width: 12rem;
font-size: clamp(1.5rem, 7vw, 2.35rem);
}
.eyebrow {
margin: 0 0 0.4rem;
color: #ffca6a;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 0.45rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 248, 236, 0.8);
font-size: 0.72rem;
font-weight: 700;
}
.status-pill__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #ffca6a;
box-shadow: 0 0 1rem rgba(255, 202, 106, 0.9);
}
.app-main {
overflow: auto;
padding: 0.5rem 0 1rem;
}
.screen {
display: grid;
gap: 1rem;
}
.hero-card,
.composer,
.timeline-card,
.empty-card,
.settings-card {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(21, 24, 39, 0.78);
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.24);
backdrop-filter: blur(18px);
}
.hero-card {
padding: 1.25rem;
}
.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 {
width: 100%;
border: 0;
border-radius: 1rem;
outline: 0;
background: rgba(255, 255, 255, 0.08);
color: #fff8ec;
}
.composer textarea {
min-height: 9rem;
resize: vertical;
padding: 1rem;
}
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
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;
}
.timeline-list {
display: grid;
gap: 0.75rem;
}
.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;
}
.bottom-nav {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.25rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.35rem;
padding: 0.35rem;
background: rgba(16, 18, 30, 0.9);
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
}
.bottom-nav__item {
display: grid;
place-items: center;
gap: 0.2rem;
min-height: 3.4rem;
border: 0;
border-radius: 1rem;
background: transparent;
color: rgba(255, 248, 236, 0.58);
font-size: 0.67rem;
font-weight: 800;
}
.bottom-nav__item[aria-current='page'] {
background: rgba(255, 202, 106, 0.16);
color: #ffca6a;
}
+1
View File
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"jsx":"react-jsx","lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client"],"noEmit":true},"references":[{"path":"../../packages/shared"}],"include":["src","vite.config.ts"]}
+9
View File
@@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});