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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T02:50:44Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
body_hash: 5d41556386df9599
raw_hash: da6fdc606e9e685b
vouched: 2026-10-22T15:31:22Z
lines: 99-282
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T12:44:15Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
body_hash: 5d41556386df9599
raw_hash: da6fdc606e9e685b
vouched: 2026-10-22T15:31:22Z
lines: 101-290
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T14:16:28Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
body_hash: 5d41556386df9599
raw_hash: da6fdc606e9e685b
vouched: 2026-10-22T15:31:22Z
lines: 104-326
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Terminal
created: 2026-10-15T03:25:42Z
norm: '1'
sig: 9ad6b07a24fa0261
body_hash: 911da63f1fbfb712
raw_hash: 90f1050382e16903
vouched: 2026-10-22T15:24:30Z
body_hash: 89146e5bb0652ae2
raw_hash: 14dc8c2e9a8e17aa
vouched: 2026-10-22T15:30:11Z
lines: 124-294
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function useComposer
created: 2026-10-16T22:41:13Z
norm: '1'
sig: 381929b0cbaf2245
body_hash: 17515b9210059870
raw_hash: 30786624d9379566
vouched: 2026-10-22T15:02:44Z
body_hash: f4f3cc3dac113b90
raw_hash: 1066200fbc3f634c
vouched: 2026-10-22T15:29:58Z
lines: 597-1226
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ id: 01M53CZ2RRXDYDPZWZ8AKNMFH0
anchor: function useDictation
created: 2026-10-16T22:22:49Z
norm: '1'
sig: de94e6fe0d9626d6
body_hash: 78e3734a05619b3c
raw_hash: 85cbb253122dfd32
sig: 85f78e4a517cc347
body_hash: 95229acf42918e4c
raw_hash: 809dc7b3b022a7fc
vouched: 2026-10-22T15:29:59Z
lines: 28-51
---

Expand Down
30 changes: 28 additions & 2 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
projectId,
} from "@ace/host/protocol";

import { chosen, deployed, remember } from "./address";
import { chosen, deployed, gateway, remember } from "./address";
import { type Attention, glow, useAttention } from "./attention";
import { Channel, ChannelDetails, type ChannelDraft } from "./channel";
import { useCompute } from "./compute";
Expand Down Expand Up @@ -107,12 +107,38 @@ function ChannelsSidebar(props: SessionSidebarProps) {
);
}

/**
* Whether the deployed app's host answers HTTP at all. A browser hides why a WebSocket failed, but
* an opaque no-cors response still proves the host is reachable, so its gateway refused this app.
*/
function useAnswers(): boolean | undefined {
const [answers, setAnswers] = useState<boolean>();
useEffect(() => {
if (!deployed || !gateway) return;
const url = new URL(gateway.replace(/^ws/, "http"));
url.pathname = "/";
fetch(url, { mode: "no-cors", cache: "no-store" }).then(
() => setAnswers(true),
() => setAnswers(false),
);
}, []);
return answers;
}

function Disconnected() {
const answers = useAnswers();
if (desktop) return <>Disconnected from Ace Helper. Open Settings → This Mac to start it.</>;
if (!deployed) return <>Disconnected from your host. Reconnecting…</>;
return (
<>
Can't reach {chosen}. Check that Tailscale is on.{" "}
{answers
? (
<>
{chosen} refused this web app. On that host, run{" "}
<code>ace web {location.origin}</code>, then reload.
</>
)
: <>Can't reach {chosen}. Check that Tailscale is on.</>}{" "}
<button
type="button"
className="underline underline-offset-2"
Expand Down
22 changes: 20 additions & 2 deletions apps/app/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
useLocalStorage,
useMedia,
} from "@ace/ui";
import type { Effort, ModelOption, ModelRef, QueuedMessage } from "@ace/channel/protocol";
import {
type Effort,
MAX_IMAGES,
type ModelOption,
type ModelRef,
type QueuedMessage,
} from "@ace/channel/protocol";
import type { Listing } from "@ace/host/protocol";

import { host } from "./host";
Expand Down Expand Up @@ -221,6 +227,18 @@ export function Composer(
// Formatting tools crowd a phone's composer; they stay one tap away.
const phone = useMedia("(width < 40rem)");

function attach(next: Attachment[]) {
let images = 0;
const kept = next.filter((item) => !item.type.startsWith("image/") || ++images <= MAX_IMAGES);
if (kept.length < next.length) {
const left = next.length - kept.length;
toast(`A message can carry ${MAX_IMAGES} images`, {
description: left === 1 ? "One image wasn't attached." : `${left} images weren't attached.`,
});
}
setAttached(kept);
}

async function send(text: string, mode: string, attached: Attachment[]) {
if (sending.current) return;
// A blocked teammate's typed "@ace" is just text.
Expand Down Expand Up @@ -331,7 +349,7 @@ export function Composer(
effort={effort}
onEffortChange={(value) => changePick({ model: picked, effort: value as Effort })}
attachments={attached}
onAttachmentsChange={setAttached}
onAttachmentsChange={attach}
busy={busy}
submitBusy={pending}
submitLabel={waiting}
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/layout/rename.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export function Rename(
required={required}
disabled={busy}
aria-invalid={!!error}
// The browser blocks a malformed submit before save() could clear a refusal.
onChange={() => setError("")}
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
Expand Down
4 changes: 3 additions & 1 deletion apps/app/src/panels/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props)
const persist = useEffectEvent(onTerminal);
if (active && !opened) setOpened(true);

useLayoutEffect(() => {
// Not a layout effect: ThemeProvider toggles the dark class in its own layout effect, which runs
// after this child's, so the tokens tone() reads would still be the old theme's.
useEffect(() => {
schemeRef.current = scheme;
if (shell.current) shell.current.options.theme = tone(scheme);
}, [scheme]);
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled
Check
</Button>
)}
{!editing && (
{/* A saved key would go unused while the environment overrides it. */}
{!editing && !provider.override && (
<Button
variant="outline"
aria-label={`${provider.stored ? "Replace" : "Add"} ${provider.name} key`}
Expand Down
5 changes: 4 additions & 1 deletion packages/channel/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,12 @@ export type GithubFile = {
error?: string;
};

/** An image as base64. Clients downscale before sending; see `MAX_IMAGES` in room.ts. */
/** An image as base64. Clients downscale before sending; see `checkAttachments` in room.ts. */
export type Image = { mimeType: string; data: string };

/** Images per message; composers refuse more before the channel would. */
export const MAX_IMAGES = 4;

/** A text file attached to a message; models read its contents beside the message's text. */
export type TextFile = { name: string; text: string };

Expand Down
3 changes: 1 addition & 2 deletions packages/channel/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "@earendil-works/pi-durable";

import { MetadataDoc } from "./metadata";
import type { Image, TextFile } from "./protocol";
import { type Image, MAX_IMAGES, type TextFile } from "./protocol";

/** A human message that did not invoke an agent. */
export const MessageEntry = defineEntry<{ author: string; text: string }>("ace.message");
Expand Down Expand Up @@ -65,7 +65,6 @@ function wrap(file: TextFile) {
* Attachments live in the transcript so every model sees them. A hosted channel stores an entry in
* one SQLite row, which Durable Objects cap at 2 MB, so a message's attachments must fit under that.
*/
export const MAX_IMAGES = 4;
const MAX_FILES = 10;
const MAX_BYTES = 1_500_000;
const TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/composer/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,10 @@ function useComposer({
let words = before + text + after;
v.dispatch(v.state.tr.insertText(words, from, from + size));
dictated.current.size = words.length;
}, () => setRecording(false));
}, (reason) => {
setRecording(false);
if (reason) toast("Voice input stopped", { description: reason });
});

let view = useRef<EditorView | null>(null);
let file = useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -1662,6 +1665,7 @@ function ComposerOptions({ state }: { state: ComposerState }) {
<TooltipTrigger
render={
<Toolbar.Button
aria-label="Formatting tools"
aria-pressed={toolbar}
onClick={() => setToolbar(t => !t)}
render={
Expand Down
40 changes: 29 additions & 11 deletions packages/ui/src/components/composer/composer-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,39 @@ type Item = {
label: string;
action: Action;
icon: ComponentType<{ className?: string }>;
/** The editor's binding (rich-text.tsx), as Mod plus these keys. */
keys?: string;
};

const MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);

/** `Shift-s` reads ⇧⌘S on a Mac and Ctrl+Shift+S elsewhere. */
function shortcut(keys: string) {
const parts = keys.split("-");
const key = parts.pop()!.toUpperCase();
if (!MAC) return ["Ctrl", ...parts, key].join("+");
const glyphs: Record<string, string> = { Shift: "⇧", Alt: "⌥" };
return `${parts.map((part) => glyphs[part]).join("")}⌘${key}`;
}

const GROUPS: Item[][] = [
[
{ label: "Bold", action: "bold", icon: BoldIcon },
{ label: "Italic", action: "italic", icon: ItalicIcon },
{ label: "Underline", action: "underline", icon: UnderlineIcon },
{ label: "Strikethrough", action: "strike", icon: StrikeIcon },
{ label: "Bold", action: "bold", icon: BoldIcon, keys: "b" },
{ label: "Italic", action: "italic", icon: ItalicIcon, keys: "i" },
{ label: "Underline", action: "underline", icon: UnderlineIcon, keys: "u" },
{ label: "Strikethrough", action: "strike", icon: StrikeIcon, keys: "Shift-s" },
],
[
{ label: "Link", action: "link", icon: LinkIcon },
],
[
{ label: "Ordered list", action: "ordered", icon: OrderedIcon },
{ label: "Bullet list", action: "bullet", icon: BulletIcon },
{ label: "Blockquote", action: "quote", icon: BlockquoteIcon },
{ label: "Ordered list", action: "ordered", icon: OrderedIcon, keys: "Alt-7" },
{ label: "Bullet list", action: "bullet", icon: BulletIcon, keys: "Alt-8" },
{ label: "Blockquote", action: "quote", icon: BlockquoteIcon, keys: "Shift-." },
],
[
{ label: "Inline code", action: "code", icon: CodeIcon },
{ label: "Code block", action: "pre", icon: CodeBlockIcon },
{ label: "Inline code", action: "code", icon: CodeIcon, keys: "e" },
{ label: "Code block", action: "pre", icon: CodeBlockIcon, keys: "Alt-`" },
],
[
{ label: "Attach", action: "attach", icon: AttachIcon },
Expand All @@ -76,7 +89,7 @@ const GROUPS: Item[][] = [
];

function ToolbarButton(
{ label, action, icon: Icon, active, disabled, onAction }: Item & {
{ label, action, icon: Icon, keys, active, disabled, onAction }: Item & {
active?: boolean;
disabled?: boolean;
onAction?: (event: ActionEvent) => void;
Expand All @@ -87,6 +100,8 @@ function ToolbarButton(
<TooltipTrigger
render={
<Toolbar.Button
aria-label={label}
aria-keyshortcuts={keys && `${MAC ? "Meta" : "Control"}+${keys.replaceAll("-", "+")}`}
render={
<Button
variant="ghost"
Expand All @@ -110,7 +125,10 @@ function ToolbarButton(
>
<Icon />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
<TooltipContent>
{label}
{keys && <kbd data-slot="kbd" className="font-sans opacity-60">{shortcut(keys)}</kbd>}
</TooltipContent>
</Tooltip>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ function MoveSection(
)}
{destinations.state === "ready" && destinations.targets.length === 0 && (
<ContextMenu.Item className={MENU_ITEM} disabled>
No available destinations
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Move to… no available destinations</span>
<span className="text-muted-foreground">
Cloud compute and a GitHub repository let a channel move
</span>
</span>
</ContextMenu.Item>
)}
{destinations.state === "ready" && destinations.targets.map((target) => {
Expand Down
15 changes: 12 additions & 3 deletions packages/ui/src/components/session-sidebar/session-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { IconDots } from "../../icons";

/** Additional ways to start a repository session, and sharing or removing the project. */
/**
* Additional ways to start a repository session, archiving inactive channels, and sharing or
* removing the project: the section's context menu, plus the starts.
*/
export function SessionOptions(
{ className, onContinuePr, onStartIssue, onShareProject, onRemoveProject }: {
{ className, onContinuePr, onStartIssue, onArchiveInactive, onShareProject, onRemoveProject }: {
className?: string;
onContinuePr?: () => void;
onStartIssue?: () => void;
onArchiveInactive?: () => void;
onShareProject?: () => void;
onRemoveProject?: () => void;
},
) {
if (!onContinuePr && !onStartIssue && !onShareProject && !onRemoveProject) return null;
if (
!onContinuePr && !onStartIssue && !onArchiveInactive && !onShareProject && !onRemoveProject
) return null;
return (
<DropdownMenu>
<Tooltip>
Expand Down Expand Up @@ -47,6 +53,9 @@ export function SessionOptions(
{onStartIssue && (
<DropdownMenuItem onClick={onStartIssue}>Start from an issue…</DropdownMenuItem>
)}
{onArchiveInactive && (
<DropdownMenuItem onClick={onArchiveInactive}>Archive inactive</DropdownMenuItem>
)}
{onShareProject && (
<DropdownMenuItem onClick={onShareProject}>Share project…</DropdownMenuItem>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ function Group({
className={actionButtonClassName}
onContinuePr={onContinuePr}
onStartIssue={onStartIssue}
onArchiveInactive={archiveInactive}
onShareProject={shareProject}
onRemoveProject={group.onRemove}
/>
Expand Down
Loading