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 App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: cffc6c2ce7bd7e6d
raw_hash: 14cee7a4562529f6
vouched: 2026-10-23T12:32:53Z
body_hash: 0c4a35fb396f0dd1
raw_hash: 95e503cbbd9a6cab
vouched: 2026-10-23T12:46:13Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: cffc6c2ce7bd7e6d
raw_hash: 14cee7a4562529f6
vouched: 2026-10-23T12:32:53Z
body_hash: 0c4a35fb396f0dd1
raw_hash: 95e503cbbd9a6cab
vouched: 2026-10-23T12:46:13Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-15T04:47:23Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: cb848608fe3526ea
raw_hash: 97a687d2dea1a6e3
vouched: 2026-10-23T12:25:08Z
body_hash: 5b0f98c8033bcdda
raw_hash: ec46efe56f9ba131
vouched: 2026-10-23T12:46:13Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-22T14:31:16Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: cb848608fe3526ea
raw_hash: 97a687d2dea1a6e3
vouched: 2026-10-23T12:25:08Z
body_hash: 5b0f98c8033bcdda
raw_hash: ec46efe56f9ba131
vouched: 2026-10-23T12:46:13Z
lines: 332-614
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: bda882f4e56d7c30
raw_hash: 291f5c1b799528ff
vouched: 2026-10-23T12:27:33Z
body_hash: 05e5d9affea62a1b
raw_hash: b285921bed152d03
vouched: 2026-10-23T12:46:13Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: bda882f4e56d7c30
raw_hash: 291f5c1b799528ff
vouched: 2026-10-23T12:27:33Z
body_hash: 05e5d9affea62a1b
raw_hash: b285921bed152d03
vouched: 2026-10-23T12:46:13Z
lines: 1-1309
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-13T17:41:10Z
norm: '1'
sig: 4b03c8eb05d682d4
body_hash: d3b539175eba5e12
raw_hash: 6e008c8c2632d2b4
vouched: 2026-10-22T21:10:47Z
body_hash: f52ecd279152f67f
raw_hash: a18302a5ba37aa01
vouched: 2026-10-23T12:46:13Z
lines: 1-39
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MCR68GHN6F4RC5G1TQZMHG
anchor: '@file'
created: 2026-10-23T12:46:08Z
norm: '1'
sig: 4b03c8eb05d682d4
body_hash: f52ecd279152f67f
raw_hash: a18302a5ba37aa01
lines: 1-52
---

Ace Helper's config.command must match the link apps/desktop/src/index.ts installs: ace for stable, ace-<channel> otherwise
7 changes: 4 additions & 3 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ function Disconnected() {
const answers = useAnswers();
const refusal = useRefused();
if (desktop) return <>Disconnected from Ace Helper. Open Settings → This Mac to start it.</>;
if (typeof refusal === "object") return <>{refusal.other}</>;
if (refusal === "signed-out") {
if (refusal && "other" in refusal) return <>{refusal.other}</>;
if (refusal) {
return (
<>
This browser isn't signed in to your host. Run <code>ace open</code>{" "}
This browser isn't signed in to your host. Run <code>{refusal.command} open</code>{" "}
on that Mac, or open its sign-in link again.
</>
);
Expand Down Expand Up @@ -1066,6 +1066,7 @@ export function App() {
)}
{card && (
<DirectoryCard
serving={directory.serving}
offered={directory.offered}
onDismiss={() => setDismissed(true)}
onChange={() => setSettings("team")}
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ window.addEventListener("hashchange", () => {
* with a new token, or because it is a teammate's host (403, whose text names its owner). A
* browser hides a WebSocket's status, so this asks over HTTP with what the socket sends.
*/
export async function refused(): Promise<"signed-out" | { other: string } | undefined> {
export async function refused(): Promise<{ command: string } | { other: string } | undefined> {
const response = await fetch(gateway!.replace(/^ws/, "http"), {
cache: "no-store",
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
}).catch(() => undefined);
if (response?.status === 401) return "signed-out";
if (response?.status === 401) return { command: await response.text() };
if (response?.status === 403) return { other: await response.text() };
}

Expand Down
25 changes: 15 additions & 10 deletions apps/app/src/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,12 @@ export function Welcome({ github, onClose }: { github?: string; onClose: () => v
}

/**
* Sidebar footer while this host has no directory: join the one a teammate's host names, or
* create one when nobody has.
* Sidebar footer while this host has no directory: rejoin the one it still serves, join the one a
* teammate's host names, or create one when nobody has.
*/
export function DirectoryCard(
{ offered, onDismiss, onChange }: {
{ serving, offered, onDismiss, onChange }: {
serving: boolean;
offered?: { url: string; host: string };
onDismiss: () => void;
onChange: () => void;
Expand All @@ -256,11 +257,13 @@ export function DirectoryCard(
const [joining, setJoining] = useState(false);
const [error, setError] = useState("");

async function join(url: string) {
async function join(url?: string) {
setJoining(true);
setError("");
try {
await host.request({ op: "directory-set", url });
await host.request(
url ? { op: "directory-set", url } : { op: "directory-serve", serve: true },
);
} catch (error) {
setError((error as Error).message);
} finally {
Expand All @@ -282,21 +285,23 @@ export function DirectoryCard(
<IconX className="size-3.5" aria-hidden />
</button>
</div>
{offered
{/* `ace directory off` leaves the directory this Mac still serves; Join serves it again. */}
{serving || offered
? (
<>
<p className="text-xs leading-5 text-muted-foreground">
{offered.host}{" "}
serves your team's directory. This Mac isn't in it, so its channels aren't listed
while it's offline.
{serving
? "This Mac serves your team's directory but isn't in it"
: `${offered?.host} serves your team's directory. This Mac isn't in it`}, so its
channels aren't listed while it's offline.
</p>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button
size="sm"
className="flex-1"
disabled={joining}
onClick={() => void join(offered.url)}
onClick={() => void join(serving ? undefined : offered?.url)}
>
{joining ? "Joining…" : "Join"}
</Button>
Expand Down
4 changes: 2 additions & 2 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ async function main() {
}
const { directory: status, tailscale } = await local<Diagnostics>({ op: "diagnostics" });
if (!status.url) return console.log("No directory");
console.log(status.url);
console.log(url ? `Joined ${status.url}` : status.url);
if (tailscale.loopback) {
return console.log(dim("This host runs with --loopback, so it doesn't sync it."));
}
Expand Down Expand Up @@ -594,7 +594,7 @@ async function main() {
url.hash = new URLSearchParams({ token: token() }).toString();
const result = Bun.spawnSync([process.platform === "darwin" ? "open" : "xdg-open", url.href]);
if (!result.success) throw new Error("Could not open Ace in your browser");
return;
return console.log(`Opened ${url.origin} in your browser, signed in to this host.`);
}
case "serve":
return serve(Number(flags.port || config.port), !!flags.loopback, flags.demo);
Expand Down
3 changes: 2 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,8 @@ function owner(port: number): Server<Client> {
});
}
if (url.pathname === "/ws") {
if (!access.authorizes(request)) return new Response("Unauthorized", { status: 401 });
// The body names this build's command, so a signed-out page can say what to run.
if (!access.authorizes(request)) return new Response(config.command, { status: 401 });
const protocols = request.headers.get("sec-websocket-protocol")?.split(",").map((value) =>
value.trim()
);
Expand Down
5 changes: 4 additions & 1 deletion apps/host/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ config.version = ACE_VERSION;
config.app = join(dirname(process.execPath), "..", "Resources", "app", "web");
config.services = join(dirname(process.execPath), "..", "Resources", "services");
config.worker = [process.execPath, "--worker"];
// The app links its command as `ace` for stable and `ace-<channel>` otherwise, as its identifier ends.
config.command = cli
? basename(process.argv0)
: ACE_IDENTIFIER.replace("dev.ace.desktop", "ace").replace(".", "-");

if (cli) {
config.command = basename(process.argv0);
await import("./cli");
} else if (worker) {
// A compiled executable dispatches its worker entry itself, rather than running a TS file.
Expand Down
15 changes: 11 additions & 4 deletions packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,19 +734,26 @@ function GroupView(props: GroupProps) {
onRowEnter(node?.dataset.messageId || uid);
})
: undefined;
let toolbar = (id: string, fit: number, sticky?: boolean) =>
active === id && (
let toolbar = (id: string, fit: number, sticky?: boolean) => {
if (active !== id) return false;
let actions = typeof props.actions === "function" ? props.actions(id) : props.actions;
let canEdit = own && !!editable?.has(id);
// A message with nothing left to act on, such as a deleted one, gets no toolbar.
let shared = (["react", "copy", "permalink"] as const).every((key) => actions?.[key] === false);
if (shared && !canEdit && !(own && actions?.delete !== false)) return false;
return (
<FloatingToolbar
align={mine ? "left" : "right"}
actions={typeof props.actions === "function" ? props.actions(id) : props.actions}
actions={actions}
mine={own}
canEdit={own && !!editable?.has(id)}
canEdit={canEdit}
sticky={sticky}
width={fit}
onAction={(action) => onToolbarAction?.(id, action)}
onOpenChange={onOpenChange}
/>
);
};

return (
<div
Expand Down