@hanzo/rooms 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Chat.js CHANGED
@@ -48,8 +48,8 @@ import { Meters } from "./meters.js";
48
48
  import { useHydrated } from "./lib/hydrated.js";
49
49
  import { base, served } from "./lib/ai.js";
50
50
  import { onServed } from "./lib/served.js";
51
- import { speech, useVoice, Voice } from "@hanzo/voice";
52
- import { ArrowUp, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
51
+ import { REFUSED, speech, useDictation, useTalk, useVoice, Voice } from "@hanzo/voice";
52
+ import { ArrowUp, AudioLines, Image as ImageMark, Mic, Paperclip, Square, Star, PanelRight, X } from "lucide-react";
53
53
  import { ENSO, FREE } from "./lib/ai.js";
54
54
  import { openThread, useOpen } from "./open.js";
55
55
  import { Beside, Framed } from "./Shell.js";
@@ -617,18 +617,21 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
617
617
  const depth = useRef(0);
618
618
  useSeed(initial, setDraft);
619
619
  // DICTATION through Hanzo's own ear: the mic records and each pause is
620
- // posted to /v1/audio/transcriptions (`zen-scribe`). `speech()` defaults to
621
- // the Hanzo gateway and `base()` is the one place a host is read from, so
622
- // pointing a local cloud at one points both. The token is the reader's own —
623
- // the package never invents a credential — and a refusal is worn by
624
- // `<Voice/>`, never passed off as a transcript.
620
+ // posted to /v1/audio/transcriptions (`zen-scribe`), and what was said lands
621
+ // AFTER what the draft already held (`useDictation`) — a pause between two
622
+ // sentences never loses the first. `speech()` defaults to the Hanzo gateway
623
+ // and `base()` is the one place a host is read from, so pointing a local
624
+ // cloud at one points both. The token is the reader's own — the package never
625
+ // invents a credential — and a refusal is worn by `<Voice/>`, never passed off
626
+ // as a transcript.
625
627
  const { token } = useIamToken();
626
628
  const ear = useMemo(() => speech({ ...base(), ...(token ? { token } : {}) }), [token]);
627
- const voice = useVoice({
628
- speech: ear,
629
- onPartial: setDraft,
630
- onUtterance: setDraft,
631
- });
629
+ const voice = useVoice({ speech: ear, ...useDictation(draft, setDraft) });
630
+ // TALK MODE: a hands-free conversation on Hanzo's realtime socket, /v1/voice.
631
+ // The mic streams up, the answer streams back as speech with its words in
632
+ // `talk.reply`, and talking over it stops it. Its own conversation, so its
633
+ // turns are drawn above the composer rather than posted into the thread.
634
+ const talk = useTalk({ ...base(), ...(token ? { token } : {}), org: org() ?? undefined });
632
635
  // WHILE THE MIC IS OPEN, A FINISHED REPLY IS READ ALOUD by the platform's
633
636
  // voice, through the conversation's own mouth so speaking over it stops it.
634
637
  // A room's reply is read part by part, each in its speaker's cast voice.
@@ -639,11 +642,12 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
639
642
  const lastMsg = msgs[msgs.length - 1];
640
643
  if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content === "string") {
641
644
  const parts = room.length > 1 ? speakers(lastMsg.content, room) : [{ who: withWhom, text: lastMsg.content }];
645
+ // A part spoken over ends the reading rather than handing on to the next.
642
646
  void (async () => {
643
647
  for (const part of parts) {
644
648
  const said = cleanForSpeech(part.text);
645
- if (said)
646
- await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined));
649
+ if (said && !(await voice.say(said, voiceOf(part.who ?? withWhom ?? undefined))))
650
+ break;
647
651
  }
648
652
  })();
649
653
  }
@@ -1047,8 +1051,18 @@ function Thread({ placeholder = message(), initial, anonymous = false, heading,
1047
1051
  },
1048
1052
  // WHAT GOES WITH THE MESSAGE, above the frame: one chip a file, each
1049
1053
  // with its own remove, and the reason any file was refused.
1050
- head: files.length || turnedAway ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [one.kind === "image" ? (_jsx(ImageMark, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })) : (_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => drop(at, one.id), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null] })) : undefined,
1051
- send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
1054
+ head: files.length || turnedAway || talk.open || talk.refusal ? (_jsxs(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", gap: "$1.5", children: [files.length ? (_jsx(XStack, { gap: "$1.5", flexWrap: "wrap", role: "list", "aria-label": "Attached files", "data-slot": "composer-attached", children: files.map((one) => (_jsxs(XStack, { role: "listitem", items: "center", gap: "$1.5", pl: "$2.5", pr: "$1", py: "$1", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", bg: "$hover", maxW: 260, children: [one.kind === "image" ? (_jsx(ImageMark, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })) : (_jsx(Paperclip, { size: 12, "aria-hidden": true, color: "var(--muted-foreground)" })), _jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: one.name }), _jsx(Box, { render: "button", "aria-label": `Remove ${one.name}`, onClick: () => drop(at, one.id), p: "$1", rounded: "$10", hoverStyle: { bg: "$raised" }, children: _jsx(X, { size: 12, "aria-hidden": true }) })] }, one.id))) })) : null, turnedAway ? (_jsx(Text, { role: "alert", fontSize: "$2", color: BAD, children: turnedAway })) : null, talk.open || talk.refusal ? (_jsx(Text, { role: "status", "aria-live": "polite", "data-slot": "composer-talk-reply", fontSize: "$2", color: talk.refusal ? BAD : "$soft", children: talk.refusal
1055
+ ? REFUSED.lost
1056
+ : talk.reply
1057
+ ? `Hanzo: ${talk.reply}`
1058
+ : talk.state === "listening"
1059
+ ? "Listening — speak, and Hanzo answers aloud."
1060
+ : "Connecting…" })) : null] })) : undefined,
1061
+ send: (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Enso, { effort: effort, onEffort: setEffort, model: wanted, onModel: choose, models: offered }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: voice, says: { idle: "Dictate", listening: "Dictating — click to stop" }, children: (state) => (_jsx(Mic, { size: 15, fill: state === "idle" ? "none" : "currentColor" })) }) }), _jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: talk, "data-slot": "composer-talk", says: {
1062
+ idle: "Talk with Hanzo",
1063
+ listening: "In conversation — click to hang up",
1064
+ speaking: "Hanzo is speaking — talk to interrupt",
1065
+ }, children: () => _jsx(AudioLines, { size: 15 }) }) }), _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": chat.streaming ? "Stop" : "Send", opacity: chat.streaming || draft.trim() || files.length ? 1 : 0.4, onClick: () => (chat.streaming ? chat.stop() : submit()), children: chat.streaming ? (_jsx(Square, { size: 13, fill: "currentColor" })) : (_jsx(ArrowUp, { size: 15, strokeWidth: 2.5 })) })] })),
1052
1066
  // WHAT THE PLAN HAS LEFT, under the frame and at its measure, for a
1053
1067
  // plan holder only: Free keeps the free lane's own notice.
1054
1068
  foot: held && limits && (limits.session || limits.day || limits.month) ? (_jsx(YStack, { width: "calc(100% - 32px)", maxW: `calc(${MEASURE} - 3rem)`, mx: "auto", children: _jsx(Meters, { limits: limits }) })) : undefined,
package/dist/Meet.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
- import { Video, VideoOff, Mic, MicOff, PhoneOff, Copy, Check, Plus, Calendar, ExternalLink, Bot, Volume2, Square, Send, UserPlus, Tv, LayoutGrid, ArrowRight, } from 'lucide-react';
4
+ import { Video, VideoOff, Mic, MicOff, PhoneOff, Copy, Check, Plus, Calendar, ExternalLink, Bot, Volume2, Square, Send, UserPlus, Tv, LayoutGrid, ArrowRight, Captions, } from 'lucide-react';
5
5
  import { Text, View, XStack, YStack } from '@hanzo/gui';
6
6
  import { Button } from '@hanzo/ui';
7
7
  import { useIamToken } from '@hanzo/iam/react';
8
- import { REFUSED, speech, useVoice } from '@hanzo/voice';
8
+ import { REFUSED, speech, useTranscript, useVoice } from '@hanzo/voice';
9
9
  import { Face, voiceOf } from './cast.js';
10
10
  import { member } from './team.js';
11
11
  import { cleanForSpeech, speakAgent, stopAgentSpeech, AudioWave, useAgentSpeech } from './speech.js';
@@ -220,8 +220,20 @@ export function Meet() {
220
220
  onUtterance: (transcript) => void handleSpokenInput(transcript),
221
221
  });
222
222
  const handsFreeSay = handsFree.say;
223
+ // LIVE TRANSCRIPT: what is said in the call lands in its notes as it settles,
224
+ // on Hanzo's growing transcript (/v1/audio/transcript), and the line under the
225
+ // notes shows the words still being decoded. Silence is neither decoded nor
226
+ // billed. No SpeechRecognition anywhere, so every browser that can record
227
+ // takes part.
228
+ const [caption, setCaption] = useState('');
229
+ const transcriber = useTranscript({
230
+ speech: ear,
231
+ onPartial: setCaption,
232
+ onSettled: (text) => setLiveNotes((prev) => [...prev, `${clock()}: You: ${text}`]),
233
+ });
234
+ const transcriberNote = transcriber.reason ?? (transcriber.refusal ? REFUSED.lost : null);
223
235
  const handleSpokenInput = useCallback(async (transcript) => {
224
- const time = new Date().toLocaleTimeString().slice(0, 5);
236
+ const time = clock();
225
237
  setLiveNotes((prev) => [...prev, `${time}: You (voice): ${transcript}`]);
226
238
  try {
227
239
  const reply = await generateAgentMeetingResponse(transcript, activeCoPilotAgent, token);
@@ -238,7 +250,7 @@ export function Meet() {
238
250
  if (!q)
239
251
  return;
240
252
  setAgentQuestion('');
241
- const time = new Date().toLocaleTimeString().slice(0, 5);
253
+ const time = clock();
242
254
  setLiveNotes((prev) => [...prev, `${time}: You: ${q}`]);
243
255
  try {
244
256
  const reply = await generateAgentMeetingResponse(q, activeCoPilotAgent, token);
@@ -263,9 +275,12 @@ export function Meet() {
263
275
  ['grid', 'Agent Grid', LayoutGrid],
264
276
  ].map(([view, label, Icon]) => (_jsxs(Button, { size: "sm", variant: meetingView === view ? 'primary' : 'ghost', ...(meetingView === view ? null : { color: '$soft' }), "aria-pressed": meetingView === view, onClick: () => setMeetingView(view), children: [_jsx(Icon, { size: 12 }), label] }, view))) }), botAssisting ? (_jsx(View, { display: "none", "$max-md": { display: 'flex' }, children: _jsxs(Button, { size: "sm", variant: copilot ? 'secondary' : 'default', onClick: () => setCopilot((v) => !v), "aria-label": "Co-pilot", "aria-expanded": copilot, "aria-controls": "meet-copilot", children: [_jsx(Bot, { size: 13 }), "Co-pilot"] }) })) : null, _jsxs(Button, { size: "sm", onClick: () => handleCopyLink(activeMeetingRoom), children: [copied ? _jsx(Check, { size: 13 }) : _jsx(Copy, { size: 13 }), copied ? 'Link Copied' : 'Copy Room Link'] }), _jsxs(Button, { size: "sm", variant: "destructive", onClick: () => {
265
277
  stopAgentSpeech();
266
- // Leaving the call hangs up the hands-free conversation with it.
278
+ // Leaving the call hangs up the hands-free conversation and
279
+ // settles the transcript with it.
267
280
  if (handsFree.open)
268
281
  handsFree.toggle();
282
+ if (transcriber.open)
283
+ transcriber.toggle();
269
284
  setActiveMeetingRoom(null);
270
285
  }, children: [_jsx(PhoneOff, { size: 14 }), "Leave Call"] })] })) : (_jsx(XStack, { items: "center", gap: 8, shrink: 1, minW: "auto", children: _jsx(Button, { asChild: true, variant: "linkMuted", size: "sm", px: 0, children: _jsxs("a", { href: "https://meet.hanzo.ai", target: "_blank", rel: "noopener noreferrer", children: ["meet.hanzo.ai", _jsx(ExternalLink, { size: 12 })] }) }) }))] }), activeMeetingRoom ? (_jsxs(XStack, { flex: 1, overflow: "hidden", position: "relative", children: [_jsx(XStack, { flex: 1, minW: "auto", position: "relative", bg: "$sunken", children: meetingView === 'iframe' ? (_jsx(View, { render: _jsx("iframe", { src: `https://meet.hanzo.ai/${activeMeetingRoom}#config.prejoinPageEnabled=false&config.startWithAudioMuted=${!micOn}&config.startWithVideoMuted=${!cameraOn}`, allow: "camera; microphone; display-capture; autoplay; clipboard-write; speaker-selection; screen-wake-lock", title: `Hanzo Meet Room: ${activeMeetingRoom}` }), width: "100%", height: "100%", borderWidth: 0, bg: "$sunken" }, "stage")) : (_jsxs(View, { flex: 1, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 12, p: 16, overflowY: "auto", bg: "$background", children: [_jsx(YStack, { bg: "$panel", rounded: 12, borderWidth: 1, borderColor: "$borderColor", position: "relative", overflow: "hidden", items: "center", justify: "center", minH: 220, children: _jsxs(YStack, { items: "center", children: [_jsx(YStack, { width: 72, height: 72, rounded: 9999, bg: "$raised", items: "center", justify: "center", mb: 8, children: _jsx(Text, { fontSize: "$8", fontWeight: "700", color: "$ink", children: "You" }) }), _jsx(Text, { fontSize: "$2", fontWeight: "600", color: "$ink", children: "Alex (Host)" }), _jsx(Text, { fontSize: "$1", color: micOn ? GOOD : '$soft', children: micOn ? 'Mic Active' : 'Muted' })] }) }), invitedAgents.map((agentName) => {
271
286
  const voice = voiceOf(agentName);
@@ -283,12 +298,12 @@ export function Meet() {
283
298
  z: 'var(--z-sheet)',
284
299
  borderLeftWidth: 0,
285
300
  }, children: [_jsxs(XStack, { items: "center", justify: "space-between", children: [_jsxs(XStack, { items: "center", gap: 8, children: [_jsx(Face, { name: activeCoPilotAgent, size: 28 }), _jsxs(YStack, { children: [_jsx(Text, { fontSize: "$2", fontWeight: "700", color: "$ink", children: activeCoPilotAgent }), _jsxs(Text, { fontSize: FLOOR, color: "$faint", children: ["AI Co-pilot \u00B7 Voice: ", voiceOf(activeCoPilotAgent)] })] })] }), _jsx(Text, { fontSize: FLOOR, fontWeight: "700", color: GOOD, bg: "$hover", px: 6, py: 2, rounded: 4, children: "Live in call" })] }), _jsx(XStack, { gap: 4, flexWrap: "wrap", children: invitedAgents.map((agent) => (_jsx(Button, { size: "sm", variant: activeCoPilotAgent === agent ? 'secondary' : 'ghost', color: activeCoPilotAgent === agent ? '$ink' : '$soft', "aria-pressed": activeCoPilotAgent === agent, onClick: () => setActiveCoPilotAgent(agent), children: agent }, agent))) }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, variant: speaking ? 'secondary' : 'default', disabled: !speaking && !liveNotes.length, onClick: handleReadBriefing, children: speaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: "var(--state-success)" }), "Stop Briefing", _jsx(Square, { size: 11, fill: "currentColor" })] })) : (_jsxs(_Fragment, { children: [_jsx(Volume2, { size: 14 }), "Briefing"] })) }), _jsxs(Button, { size: "sm", variant: handsFree.open ? 'secondary' : 'default', "data-testid": "hands-free-voice-toggle", "data-state": handsFree.state, "data-refusal": handsFree.refusal ? handsFree.refusal.service : undefined, "aria-pressed": handsFree.open, disabled: !!handsFree.blocked, title: handsFreeNote ??
286
- (handsFree.open ? 'Hands-Free Voice Active (Speaks and interrupts on voice)' : 'Enable Hands-Free Voice Mode'), onClick: handsFree.toggle, children: [_jsx(Mic, { size: 14, color: handsFree.open ? 'var(--state-success)' : 'currentColor' }), handsFree.open ? 'Hands-Free On' : 'Hands-Free'] })] }), handsFreeNote ? (_jsx(Text, { fontSize: "$1", color: "$soft", role: "status", children: handsFreeNote })) : null, _jsx(YStack, { flex: 1, overflowY: "auto", bg: "$background", rounded: 8, borderWidth: 1, borderColor: "$borderColor", p: 12, gap: 8, children: liveNotes.map((note, i) => (_jsx(Text, { fontSize: "$1", color: "$quiet", lineHeight: 16.5, pb: 8, borderBottomWidth: 1, borderBottomColor: "$borderColor", children: note }, i))) }), _jsxs(XStack, { render: "form", onSubmit: handleAskAgent, gap: 6, children: [_jsx(Text, { render: _jsx("input", { type: "text", value: agentQuestion, onChange: (e) => setAgentQuestion(e.target.value), placeholder: `Ask ${activeCoPilotAgent} in meeting...` }), flex: 1, bg: "$background", borderWidth: 1, borderColor: "var(--border-control)", rounded: 6, px: 10, py: 8, color: "$ink", fontSize: "$1", outlineStyle: "none" }), _jsx(Button, { type: "submit", variant: "primary", size: "icon-sm", disabled: !agentQuestion.trim(), "aria-label": "Ask", children: _jsx(Send, { size: 12 }) })] }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, disabled: !agentQuestion.trim(), title: "Add what is typed above to the notes, without asking the co-pilot", onClick: () => {
301
+ (handsFree.open ? 'Hands-Free Voice Active (Speaks and interrupts on voice)' : 'Enable Hands-Free Voice Mode'), onClick: handsFree.toggle, children: [_jsx(Mic, { size: 14, color: handsFree.open ? 'var(--state-success)' : 'currentColor' }), handsFree.open ? 'Hands-Free On' : 'Hands-Free'] }), _jsxs(Button, { size: "sm", variant: transcriber.open ? 'secondary' : 'default', "data-testid": "live-transcript-toggle", "data-state": transcriber.state, "data-refusal": transcriber.refusal ? transcriber.refusal.service : undefined, "aria-pressed": transcriber.open, disabled: !!transcriber.blocked, title: transcriberNote ?? (transcriber.open ? 'Transcribing — click to stop' : 'Transcribe what is said into the notes'), onClick: transcriber.toggle, children: [_jsx(Captions, { size: 14, color: transcriber.open ? 'var(--state-success)' : 'currentColor' }), transcriber.open ? 'Transcribing' : 'Transcribe'] })] }), handsFreeNote ? (_jsx(Text, { fontSize: "$1", color: "$soft", role: "status", children: handsFreeNote })) : null, transcriberNote ? (_jsx(Text, { fontSize: "$1", color: "$soft", role: "status", children: transcriberNote })) : null, _jsxs(YStack, { flex: 1, overflowY: "auto", bg: "$background", rounded: 8, borderWidth: 1, borderColor: "$borderColor", p: 12, gap: 8, children: [liveNotes.map((note, i) => (_jsx(Text, { fontSize: "$1", color: "$quiet", lineHeight: 16.5, pb: 8, borderBottomWidth: 1, borderBottomColor: "$borderColor", children: note }, i))), transcriber.open && caption ? (_jsx(Text, { "data-testid": "live-caption", fontSize: "$1", color: "$soft", lineHeight: 16.5, "aria-live": "polite", children: caption })) : null] }), _jsxs(XStack, { render: "form", onSubmit: handleAskAgent, gap: 6, children: [_jsx(Text, { render: _jsx("input", { type: "text", value: agentQuestion, onChange: (e) => setAgentQuestion(e.target.value), placeholder: `Ask ${activeCoPilotAgent} in meeting...` }), flex: 1, bg: "$background", borderWidth: 1, borderColor: "var(--border-control)", rounded: 6, px: 10, py: 8, color: "$ink", fontSize: "$1", outlineStyle: "none" }), _jsx(Button, { type: "submit", variant: "primary", size: "icon-sm", disabled: !agentQuestion.trim(), "aria-label": "Ask", children: _jsx(Send, { size: 12 }) })] }), _jsxs(XStack, { gap: 8, children: [_jsx(Button, { size: "sm", flex: 1, disabled: !agentQuestion.trim(), title: "Add what is typed above to the notes, without asking the co-pilot", onClick: () => {
287
302
  const note = agentQuestion.trim();
288
303
  if (!note)
289
304
  return;
290
305
  setAgentQuestion('');
291
- setLiveNotes((prev) => [...prev, `${new Date().toLocaleTimeString().slice(0, 5)}: You (note): ${note}`]);
306
+ setLiveNotes((prev) => [...prev, `${clock()}: You (note): ${note}`]);
292
307
  }, children: "+ Add Live Note" }), _jsx(Button, { variant: "primary", size: "sm", onClick: () => {
293
308
  const text = liveNotes.join('\n');
294
309
  navigator.clipboard.writeText(text);
@@ -311,3 +326,7 @@ export function Meet() {
311
326
  setActiveMeetingRoom(m.roomName);
312
327
  }, children: [_jsx(Video, { size: 12 }), "Join Call"] }), _jsx(Button, { size: "icon-sm", "aria-label": "Copy invite link", title: "Copy invite link", onClick: () => handleCopyLink(m.roomName), children: _jsx(Copy, { size: 12 }) })] })] }, m.id))) }), _jsxs(YStack, { mt: "auto", p: 12, bg: "$background", rounded: 8, borderWidth: 1, borderColor: "$borderColor", gap: 6, children: [_jsxs(XStack, { items: "center", gap: 8, children: [_jsx(Calendar, { size: 14, color: "var(--muted-foreground)" }), _jsx(Text, { fontSize: "$2", fontWeight: "700", color: "$ink", children: "Need to book a slot?" })] }), _jsx(Text, { fontSize: "$1", color: "$soft", children: "Use the Hanzo Calendar Scheduler to coordinate available times across team members and bots." }), _jsx(Button, { asChild: true, variant: "link", size: "sm", px: 0, justify: "flex-start", children: _jsxs("a", { href: "/cal", children: ["Open Calendar Scheduler", _jsx(ArrowRight, { size: 11 })] }) })] })] })] }))] }));
313
328
  }
329
+ /** A note's time, as the reader's clock shows it: 4:22 PM, never "4:22:". */
330
+ function clock() {
331
+ return new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,7 +58,7 @@
58
58
  "@hanzo/plans": ">=1.8.9",
59
59
  "@hanzo/ui": ">=8.27.51",
60
60
  "@hanzo/usage": ">=0.1.17",
61
- "@hanzo/voice": ">=0.1.10",
61
+ "@hanzo/voice": ">=0.1.11",
62
62
  "@hanzogui/shell": "^8.3.20",
63
63
  "lucide-react": ">=1.42.0",
64
64
  "react": ">=19.2.8",
@@ -77,7 +77,7 @@
77
77
  "@hanzo/plans": "1.8.9",
78
78
  "@hanzo/ui": "8.27.51",
79
79
  "@hanzo/usage": "0.1.17",
80
- "@hanzo/voice": "0.1.10",
80
+ "@hanzo/voice": "0.1.11",
81
81
  "@hanzogui/config": "8.3.5",
82
82
  "@hanzogui/core": "8.3.5",
83
83
  "@hanzogui/lucide-icons-2": "8.3.5",