composer: what the gear says is read when it changes; the mic and voice are plain marks on a laptop; a phone gives where its own line

This commit is contained in:
2026-10-20 11:53:13 -07:00
parent f10d47bac7
commit 4055bf5827
4 changed files with 60 additions and 38 deletions
+44 -32
View File
@@ -1,21 +1,21 @@
/**
* What a message carries beside its words, assembled once for Chat and Dev:
* the conversation's gear (gear.ts) drawn as the `+` menu's shelves and the
* chips after `+`, and `brief` — the words the gear adds to the message, read
* just before it goes.
* chips after `+`, and `text` — the words the gear adds to every message, read
* again whenever the gear changes.
*
* skills each picked skill's SKILL.md (the org's own, or the brand's
* catalogue document): the platform's completion takes no skills
* field, so the text rides the message
* memory what Hanzo remembers about the person that bears on the ask
* (GET /v1/ai/memory/recall): the platform injects none itself
* memory what Hanzo remembers about the person (GET /v1/ai/memory/list):
* the platform injects none itself
* mode Plan: lay the plan out and wait; Auto says nothing
*
* A shelf is offered only where its service answers; a surface adds its own
* (Chat its agents, Dev its devices) beside these.
*/
import { BookOpen, ListChecks, ScrollText } from '@hanzogui/lucide-icons-2'
import { useCallback, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { call, query, unwrap, type Target } from './api/call.ts'
import { active, authored, document, nameOf as skillOf } from './api/skills.ts'
@@ -27,26 +27,44 @@ import type { Chip } from './prompt.tsx'
/** The most of each skill a message carries. */
const SKILL_MAX = 16_000
/** The memories one message carries at most. */
const RECALL = 8
/** The memories every message carries at most, newest first. */
const RECALL = 20
export interface Carry {
gear: GearState
skills: Shelf
/** The chips after `+`: the skills on, Research, and Plan. */
on: Chip[]
/** The words the gear adds to a message about `text`, '' when none. */
brief: (text: string) => Promise<string>
/** The words the gear adds to every message, '' when none. */
text: string
}
/** `<owner>/<name>` memories that bear on `q`, as lines. */
async function recall(t: Target, q: string): Promise<string[]> {
const rows = unwrap(await call<unknown>(t, 'GET', `/v1/ai/memory/recall${query({ q: q.slice(0, 500), limit: RECALL })}`))
/** What Hanzo remembers about the person, newest first, as lines. */
async function remembered(t: Target): Promise<string[]> {
const rows = unwrap(await call<unknown>(t, 'GET', `/v1/ai/memory/list${query({ limit: RECALL })}`))
return (Array.isArray(rows) ? rows : [])
.map((r) => (r && typeof r === 'object' ? String((r as { content?: unknown }).content ?? '') : ''))
.filter(Boolean)
}
/** The words `skills`, `memory` and `mode` add to a message. */
async function said(t: Target, skills: readonly string[], memory: boolean, mode: string): Promise<string> {
const parts: string[] = []
if (skills.length) {
const own = await authored(t).catch(() => [])
for (const n of skills) {
const body = own.find((s) => s.name === n)?.content || (await document(t, n).catch(() => ''))
if (body) parts.push(`Use the skill "${n}" where it applies:\n\n${body.slice(0, SKILL_MAX)}`)
}
}
if (memory) {
const lines = await remembered(t).catch(() => [])
if (lines.length) parts.push(`What you remember about the person:\n${lines.map((l) => `- ${l}`).join('\n')}`)
}
if (mode === 'plan') parts.push('Plan mode: lay out the plan — the steps, what each changes and why — and stop. Change nothing and produce no final work until the person says go.')
return parts.join('\n\n')
}
/**
* The conversation `key` names (`''` for one not begun), for target `t`.
* `customize(tab)` opens Dev's Customize on that tab.
@@ -75,25 +93,19 @@ export function useCarry(t: Target, key: string, customize: (tab: 'skills') => v
...(mode === 'plan' ? [{ id: 'plan', label: 'Plan', icon: <ListChecks size={14} />, onRemove: () => gear.set({ mode: 'auto' }) }] : []),
]
const brief = useCallback(
async (text: string): Promise<string> => {
const parts: string[] = []
if (skills.length) {
const own = await authored(t).catch(() => [])
for (const n of skills) {
const body = own.find((s) => s.name === n)?.content || (await document(t, n).catch(() => ''))
if (body) parts.push(`Use the skill "${n}" where it applies:\n\n${body.slice(0, SKILL_MAX)}`)
}
}
if (memory && text.trim()) {
const lines = await recall(t, text).catch(() => [])
if (lines.length) parts.push(`What you remember about the person, where it bears on this:\n${lines.map((l) => `- ${l}`).join('\n')}`)
}
if (mode === 'plan') parts.push('Plan mode: lay out the plan — the steps, what each changes and why — and stop. Change nothing and produce no final work until the person says go.')
return parts.join('\n\n')
},
[t, skills, memory, mode],
)
const signedIn = Boolean(t.token())
const [text, setText] = useState('')
const asked = skills.join('\n')
useEffect(() => {
if (!signedIn) return setText(mode === 'plan' ? 'Plan mode: lay out the plan and stop until the person says go.' : '')
let live = true
void said(t, skills, memory, mode).then((x) => live && setText(x))
return () => {
live = false
}
// `skills` is read through `asked`, its contents.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, signedIn, asked, memory, mode])
return { gear, skills: shelf, on, brief }
return { gear, skills: shelf, on, text }
}
+1 -1
View File
@@ -224,7 +224,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
setNote('')
try {
const run = await start(t, {
prompt: compose([draft.trim(), await carry.brief(draft)].filter(Boolean).join('\n\n---\n'), files),
prompt: compose([draft.trim(), carry.text].filter(Boolean).join('\n\n---\n'), files),
// The name alone. The org rides the request, and a slash is not a repo name.
// An empty base or place is not sent: the default branch, and the sandbox.
repo: repo?.name ?? hub?.full_name,
+4 -1
View File
@@ -30,6 +30,9 @@ import { LANGUAGES } from './voice.ts'
const KEY = 'hanzo.mic'
/** The mic is a plain mark until it is pointed at. */
const PLAIN = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } } as const
function kept(): string | null {
try {
return window.localStorage.getItem(KEY)
@@ -148,7 +151,7 @@ export function Dictation({ voice, disabled = false, language = null, onLanguage
hoverStyle={{ borderColor: '$borderColor' }}
shrink={0}
>
<Control asChild size={30}>
<Control asChild size={30} {...PLAIN}>
<Voice voice={voice} disabled={disabled} says={{ idle: 'Dictate', listening: 'Dictating — click to stop' }}>
{(state) => <MicIcon size={16} fill={state === 'idle' ? 'none' : 'currentColor'} />}
</Voice>
+11 -4
View File
@@ -49,6 +49,9 @@ export const ROUND = 32
const CORNER = 12
const CORNER_PHONE = 22
/** A round control with no ground of its own until it is pointed at. */
const QUIET = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } } as const
/** What every Composer placeholder says, unless a surface names who is listening. */
export const ASK = 'Type / for skills'
@@ -294,13 +297,15 @@ export function Tune({ mind, mode, where, onUpgrade }: { mind?: Mind | null; mod
const thinks = mind ? reasons(mind.model, mind.models) : false
const pace = mind ? EFFORTS.find((e) => e.id === mind.effort)! : null
return (
<XStack data-slot="tune" width="100%" items="center" gap={2} flexWrap="wrap">
<XStack data-slot="tune" width="100%" items="center" gap={2} flexWrap="wrap" rowGap={4}>
{where ? (
<XStack items="center" gap={2} flexWrap="wrap" minW={0}>
// On a phone where the run goes takes its own line, and the model, effort and mode the next.
<XStack items="center" gap={2} flexWrap="wrap" minW={0} $max-sm={{ width: '100%' }}>
{where}
</XStack>
) : null}
<XStack flex={1} />
<XStack flex={1} $max-sm={{ display: 'none' }} />
<XStack items="center" gap={2} ml="auto" shrink={0}>
{mind ? <Model mind={mind} onUpgrade={onUpgrade} /> : null}
{mind && thinks && pace ? (
<Opens label={`Effort: ${pace.label}`} word={pace.label} dim>
@@ -345,6 +350,7 @@ export function Tune({ mind, mode, where, onUpgrade }: { mind?: Mind | null; mod
</PopoverContent>
</Popover>
) : null}
</XStack>
</XStack>
)
}
@@ -535,7 +541,8 @@ export function prompt(o: PromptOptions, state?: { index: number; closed: string
<XStack items="center" gap={6} data-slot="prompt-end" shrink={0}>
{o.voice ? <Dictation voice={o.voice} disabled={disabled} language={o.language ?? null} {...(o.onLanguage ? { onLanguage: o.onLanguage } : {})} /> : null}
{!busy && !lit && o.talk ? (
<Control asChild size={ROUND} fill rounded={999}>
// Voice mode is a plain mark on a laptop, as the mic is, and the filled round on a phone.
<Control asChild size={ROUND} fill={narrow} rounded={999} {...(narrow ? null : QUIET)}>
<Voice
voice={o.talk}
disabled={disabled}