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
@@ -0,0 +1,12 @@
---
id: 01M5AZ4CKBK8Q49Z6TCNZGMVDS
anchor: class Channel
created: 2026-10-19T20:54:58Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 69f52b41268c803d
raw_hash: eb621c3d81f78e67
lines: 151-985
---

stop and kill go through #mayInvoke like ask; whoever can't start a run can't end one
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: c26ff8b1e57b818f
raw_hash: 6f6bb4ad6bd95eb3
vouched: 2026-10-19T20:51:12Z
body_hash: 5f7b73ecdc92491d
raw_hash: 92a0017bb10f2de6
vouched: 2026-10-19T20:57:54Z
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: c26ff8b1e57b818f
raw_hash: 6f6bb4ad6bd95eb3
vouched: 2026-10-19T20:51:12Z
body_hash: 5f7b73ecdc92491d
raw_hash: 92a0017bb10f2de6
vouched: 2026-10-19T20:57:54Z
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: c26ff8b1e57b818f
raw_hash: 6f6bb4ad6bd95eb3
vouched: 2026-10-19T20:51:12Z
body_hash: 5f7b73ecdc92491d
raw_hash: 92a0017bb10f2de6
vouched: 2026-10-19T20:57:54Z
lines: 104-326
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function Settings
created: 2026-10-19T20:51:03Z
norm: '1'
sig: 8d6f8142ef1a23f2
body_hash: 5ebe48e26d37220b
raw_hash: a4c1b368058b0077
body_hash: ab1332a896bdd893
raw_hash: b048e59ec08c6130
vouched: 2026-10-19T20:57:54Z
lines: 325-537
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function Settings
created: 2026-10-19T20:53:03Z
norm: '1'
sig: 8d6f8142ef1a23f2
body_hash: 632e6f14cd21b9a8
raw_hash: a17e7f834c859315
body_hash: ab1332a896bdd893
raw_hash: b048e59ec08c6130
vouched: 2026-10-19T20:57:54Z
lines: 325-545
---

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: 397cc128a3b3cb7c
raw_hash: 55881f356849ef02
vouched: 2026-10-19T16:56:54Z
body_hash: 2274b654de30a8ac
raw_hash: 23940716838db5e6
vouched: 2026-10-19T20:55:05Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T04:08:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: ef8527c95dcbf51a
raw_hash: 3d4e766de43b3348
vouched: 2026-10-19T18:26:01Z
body_hash: 67b82b247b0adc3f
raw_hash: f2b48d8004d3b7cb
vouched: 2026-10-19T20:55:05Z
lines: 266-519
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function handle
created: 2026-10-15T06:00:20Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: ef8527c95dcbf51a
raw_hash: 3d4e766de43b3348
vouched: 2026-10-19T18:26:02Z
body_hash: 67b82b247b0adc3f
raw_hash: f2b48d8004d3b7cb
vouched: 2026-10-19T20:55:06Z
lines: 281-561
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function handle
created: 2026-10-19T18:25:49Z
norm: '1'
sig: e597b5fbc8e664ce
body_hash: ef8527c95dcbf51a
raw_hash: 3d4e766de43b3348
body_hash: 67b82b247b0adc3f
raw_hash: f2b48d8004d3b7cb
vouched: 2026-10-19T20:55:06Z
lines: 379-692
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5AZ4CX0CX0QP8TMTSE6Y5B8
anchor: function authored
created: 2026-10-19T20:54:58Z
norm: '1'
sig: 57e007663f34cc44
body_hash: 642651b36a1ec33e
raw_hash: 3eb9f837a1e9ecef
lines: 382-387
---

stop and kill always get the verified author, even when an older client sent none, or the owner's own stop is refused
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function kill
created: 2026-10-16T22:01:59Z
norm: '1'
sig: 513ab7051807bd68
body_hash: c5c00bce00544277
raw_hash: 8b791f6674195e35
body_hash: 679a9e727fe00a07
raw_hash: 0c7e17ee0991afa6
vouched: 2026-10-19T20:55:06Z
lines: 116-143
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 5a0314d40ff6a649
raw_hash: 6384441db3491561
vouched: 2026-10-19T20:53:00Z
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 5a0314d40ff6a649
raw_hash: 6384441db3491561
vouched: 2026-10-19T20:53:00Z
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 5a0314d40ff6a649
raw_hash: 6384441db3491561
vouched: 2026-10-19T20:53:00Z
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 5a0314d40ff6a649
raw_hash: 6384441db3491561
vouched: 2026-10-19T20:53:00Z
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 5a0314d40ff6a649
raw_hash: 6384441db3491561
vouched: 2026-10-19T20:53:00Z
body_hash: b94131a282fede50
raw_hash: 0632800a47dcc747
vouched: 2026-10-19T20:57:54Z
lines: 127-873
---

Expand Down
97 changes: 33 additions & 64 deletions apps/app/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ const MODES = [
{ id: "chat", name: "Chat", placeholder: "Mention @ace to ask the agent" },
ACE_MODE,
];
// Teammates keep the Ace mode and their drafts while the owner has turned invocation off.
const BLOCKED_MODES = [{ id: "chat", name: "Chat", placeholder: "Message the channel" }, ACE_MODE];
// A teammate the owner hasn't let invoke sees no @ace; the stored mode brings it back with access.
const BLOCKED_MODES = [{ id: "chat", name: "Chat", placeholder: "Message the channel" }];
const MENTIONS = [{ name: "ace", avatar: aceAvatar }];
const NO_MENTIONS: typeof MENTIONS = [];
export const EFFORTS: Record<Effort, string> = {
off: "Off",
minimal: "Minimal",
Expand Down Expand Up @@ -60,17 +61,6 @@ type Props = {
accessory?: ReactNode;
};

/** The channel refuses these too; holding them here keeps the draft instead of failing a send. */
function useInvocation(shared: boolean | undefined, owner: boolean, mode: string) {
const [mentioned, setMentioned] = useState(false);
const blocked = shared === false && !owner;
return {
blocked,
refused: blocked && (mode === "ace" || mentioned),
onText: (text: string) => setMentioned(ACE.test(text)),
};
}

/** Below the composer: its attachment tray rises over the space above it and takes clicks there. */
export function Notice({ children, action }: { children: ReactNode; action?: ReactNode }) {
return (
Expand All @@ -84,22 +74,6 @@ export function Notice({ children, action }: { children: ReactNode; action?: Rea
);
}

/** Why a teammate's invoking draft is held while the owner has turned invocation off. */
function Refusal({ mode, onChat }: { mode: string; onChat: () => void }) {
return (
<Notice
action={
<Button size="sm" variant="ghost" onClick={onChat}>
{mode === "ace" ? "Switch to Chat" : "Remove @ace"}
</Button>
}
>
The owner has turned off teammate agent invocation.{" "}
{mode === "ace" ? "Switch to Chat" : "Remove @ace"} to send this as a message.
</Notice>
);
}

/** A failed admission keeps the draft and attachments available for retry. */
export function Composer(
{
Expand All @@ -125,7 +99,10 @@ export function Composer(
const [attached, setAttached] = useState<Attachment[]>([]);
// Kept beside the channel's draft so a deliberate mode and an unsent model pick survive relaunch.
const scope = `/channels/${channel.id}`;
const [mode, changeMode] = useLocalStorage(`ace:mode:${scope}`, "ace");
const [stored, changeMode] = useLocalStorage(`ace:mode:${scope}`, "ace");
// The channel refuses a blocked teammate's ask, stop, and kill too.
const blocked = shared === false && channel.owner !== user;
const mode = blocked ? "chat" : stored;
const [models, setModels] = useState<ModelOption[]>();
const [modelError, setModelError] = useState<string>();
const status = useSyncExternalStore(host.subscribe, () => host.status);
Expand All @@ -135,6 +112,13 @@ export function Composer(
composer.current?.set(ACE.test(draft) ? draft : `@ace ${draft}`);
onDraftLoaded();
}, [draft, onDraftLoaded]);
// A saved Ace draft keeps its words as a message; its mention returns with access, from the mode.
useEffect(() => {
if (!blocked) return;
const doc = composer.current?.get();
const text = doc ? serialize(doc) : "";
if (ACE.test(text)) composer.current?.set(text.replace(/(^|\s)@ace\b\s*/gi, "$1").trim());
}, [blocked]);
// Each withdrawn message returns once, ahead of whatever the user has typed since.
const restored = useRef(new Set<number>());
useEffect(() => {
Expand All @@ -147,7 +131,7 @@ export function Composer(
// One leading mention invokes the whole draft; an empty Ace-mode composer holds only that.
const text = [...mine.map((message) => message.text), doc ? serialize(doc) : ""]
.map((part) => part.replace(LEADING, "").trim()).filter(Boolean).join("\n\n");
composer.current?.set(`@ace ${text}`);
composer.current?.set(blocked ? text : `@ace ${text}`);
const images = mine.flatMap((message) => message.images || []);
const files = mine.flatMap((message) => message.files || []);
if (images.length || files.length) {
Expand All @@ -171,7 +155,7 @@ export function Composer(
: `Stopped before ${mine.length} of your queued messages ran`,
{ description: `${mine.length === 1 ? "It's" : "They're"} back in the composer.` },
);
}, [returned, user]);
}, [returned, user, blocked]);
// An offline channel can't answer; the conversation already says so, so don't blame providers.
const offline = channel.state === "offline";
useEffect(() => {
Expand Down Expand Up @@ -223,27 +207,15 @@ export function Composer(
}
// Automatic stays offered so a chat whose model lost its key can hand the choice back.
choices.unshift({ id: "auto", name: "Automatic", vendor: "" });
const { blocked, refused, onText } = useInvocation(shared, channel.owner === user, mode);
// A mention invokes in Chat mode too, so sending as a message drops it from the draft.
const toChat = () => {
changeMode("chat");
const doc = composer.current?.get();
const text = doc ? serialize(doc) : "";
if (ACE.test(text)) composer.current?.set(text.replace(/(^|\s)@ace\b\s*/gi, "$1").trim());
};
// Formatting tools crowd a phone's composer; they stay one tap away.
const phone = useMedia("(width < 40rem)");

async function send(text: string, mode: string, attached: Attachment[]) {
if (sending.current) return;
const invoke = mode === "ace" || ACE.test(text);
// A blocked teammate's typed "@ace" is just text.
const invoke = !blocked && (mode === "ace" || ACE.test(text));
const body = text.trim();
if (!body && !attached.length) return;
if (invoke && blocked) {
return void toast.error("Could not invoke agent", {
description: "The owner has turned off teammate agent invocation.",
});
}
// The channel would refuse it for want of a key; setup keeps the draft for sending after.
if (mode === "ace" && models?.length === 0 && !modelError && onProviders) {
return onProviders();
Expand Down Expand Up @@ -301,8 +273,7 @@ export function Composer(
modes={blocked ? BLOCKED_MODES : MODES}
mode={mode}
onModeChange={changeMode}
onTextChange={onText}
mentions={MENTIONS}
mentions={blocked ? NO_MENTIONS : MENTIONS}
models={choices}
model={model}
onModelChange={(value) => changePick({ model: value })}
Expand All @@ -314,25 +285,23 @@ export function Composer(
busy={busy}
submitBusy={pending}
submitLabel={waiting}
canStop={busy}
onStop={() => void host.channel(channel.id, { op: "stop", chat: id })}
canSend={ready && status === "open" && !pending && !refused}
canStop={busy && !blocked}
onStop={() => void host.channel(channel.id, { op: "stop", chat: id, author: user })}
canSend={ready && status === "open" && !pending}
clearOnSend={false}
onSend={({ doc, mode, attachments }) => void send(serialize(doc), mode, attachments)}
/>
{refused
? <Refusal mode={mode} onChat={toChat} />
: mode === "ace" && !offline && models?.length === 0 && (
<Notice
action={onProviders && (
<Button size="sm" variant="outline" onClick={onProviders}>
{modelError ? "Provider settings" : "Set up a provider"}
</Button>
)}
>
{modelError || "Add a provider to use Ace. You can still send messages in Chat mode."}
</Notice>
)}
{mode === "ace" && !offline && models?.length === 0 && (
<Notice
action={onProviders && (
<Button size="sm" variant="outline" onClick={onProviders}>
{modelError ? "Provider settings" : "Set up a provider"}
</Button>
)}
>
{modelError || "Add a provider to use Ace. You can still send messages in Chat mode."}
</Notice>
)}
</>
);
}
Loading