Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0857f62
feat(agent): rebuild the agent page as a Claude-style split workspace
tsahimatsliah Aug 5, 2026
8d36b9c
feat(agent): make the demo route a self-contained mock surface
tsahimatsliah Aug 5, 2026
fef894f
feat(agent): rework the workspace chrome and transcript density
tsahimatsliah Aug 5, 2026
6908ddd
fix(agent): make the run toggle actually stop the agent, and read the…
tsahimatsliah Aug 5, 2026
cda307a
fix(agent): render the demo without a backend
tsahimatsliah Aug 5, 2026
5f59e2e
feat(agent): add /dev/agent as a backend-free review surface
tsahimatsliah Aug 5, 2026
e394f98
style(agent): widen the chat gutters, grow the header controls, chip …
tsahimatsliah Aug 5, 2026
8e18e63
style(agent): put the panel on the post page's gutters
tsahimatsliah Aug 5, 2026
7ee0cde
feat(agent): add a usage panel, and make the selected tab unmistakable
tsahimatsliah Aug 5, 2026
c8f71d2
style(agent): rebuild the panel tabs on Claude Code's chip anatomy
tsahimatsliah Aug 5, 2026
7d3c116
feat(agent): show the agent thinking — logo mark, status strip, borde…
tsahimatsliah Aug 5, 2026
0ead7f2
fix(agent): rebuild the composer beam on the border-beam recipe
tsahimatsliah Aug 5, 2026
302488d
feat(agent): use the real border-beam for the working state
tsahimatsliah Aug 5, 2026
dd67a47
feat(agent): close the Claude/Codex interaction gaps, and map the dec…
tsahimatsliah Aug 5, 2026
b39971c
feat(interests): rebuild the thinking indicator as a particle field
tsahimatsliah Aug 5, 2026
eb58959
feat(interests): put the thinking-indicator states on a /dev route
tsahimatsliah Aug 5, 2026
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
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"border-beam": "1.3.0",
"check-password-strength": "^2.0.10",
"cmdk": "^1.0.0",
"edge-aura": "0.6.0",
Expand Down
229 changes: 219 additions & 10 deletions packages/shared/src/features/interests/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
UpdateInterestInput,
UserInterest,
} from '../../graphql/interests';
import { UserInterestStatus } from '../../graphql/interests';
import { useSendInterestCommand } from './hooks/useSendInterestCommand';
import { useUpdateInterest } from './hooks/useUpdateInterest';
import { useToastNotification } from '../../hooks/useToastNotification';
Expand All @@ -21,7 +22,21 @@ import { cannedReply } from './chat';

export type AgentContentTarget =
| { type: 'post'; post: Post }
| { type: 'feed'; label: string; posts: Post[] };
| { type: 'feed'; label: string; posts: Post[] }
| { type: 'activity' }
| { type: 'debug' };

export const contentTargetId = (target: AgentContentTarget): string => {
if (target.type === 'post') {
return `post:${target.post.id}`;
}

if (target.type === 'feed') {
return `feed:${target.label}`;
}

return target.type;
};

export type AgentActivityKind =
| 'run'
Expand All @@ -47,19 +62,36 @@ type RunCommandArgs = {
type AgentContextValue = {
id: string;
interest?: UserInterest;
/**
* The agent's run state. Held here rather than read off `interest` so the
* toggle responds on the demo surface too, where there is no API to write
* back to.
*/
status: UserInterestStatus;
isDemo: boolean;
isWorking: boolean;
workingLabel?: string;
/** Epoch ms the current run started, for the elapsed counter. */
workingSince?: number;
isTargetWorking: (targetId: string) => boolean;
runCommand: (args: RunCommandArgs) => void;
stopCommand: () => void;
/** Prompts sent while a run is in flight; they start as the runs finish. */
queuedCommands: { id: string; text: string }[];
removeQueuedCommand: (id: string) => void;
update: (data: UpdateInterestInput) => void;
isUpdating: boolean;
activity: AgentActivityItem[];
messages: AgentMessage[];
isSettingsOpen: boolean;
setSettingsOpen: (open: boolean) => void;
openContent: AgentContentTarget[];
activeContentId?: string;
activeContent?: AgentContentTarget;
setActiveContent: (target?: AgentContentTarget) => void;
openContentTarget: (target: AgentContentTarget) => void;
focusContent: (targetId: string) => void;
closeContent: (targetId: string) => void;
closeAllContent: () => void;
};

const AgentContext = createContext<AgentContextValue>({} as AgentContextValue);
Expand Down Expand Up @@ -87,19 +119,34 @@ export const AgentProvider = ({
const [working, setWorking] = useState<{
label: string;
targetId?: string;
startedAt: number;
} | null>(null);
const [activity, setActivity] = useState<AgentActivityItem[]>([]);
const [messages, setMessages] = useState<AgentMessage[]>(initialMessages);
const [isSettingsOpen, setSettingsOpen] = useState(false);
const [activeContent, setActiveContent] = useState<AgentContentTarget>();
const [statusOverride, setStatusOverride] = useState<UserInterestStatus>();
const status =
statusOverride ?? interest?.status ?? UserInterestStatus.Active;
const [content, setContent] = useState<{
items: AgentContentTarget[];
activeId?: string;
}>({ items: [] });
const [queuedCommands, setQueuedCommands] = useState<
{ id: string; args: RunCommandArgs }[]
>([]);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
// The completion timeout needs the *current* starter to drain the queue, and
// a plain closure would freeze the one from its own render.
const startRunRef = useRef<(args: RunCommandArgs) => void>();
const workingRef = useRef(false);

useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);

const runCommand = useCallback(
const startRun = useCallback(
({ text, label, targetId, onComplete }: RunCommandArgs) => {
workingRef.current = true;
if (!isDemo && interest) {
sendCommand(text).catch(() => undefined);
} else {
Expand All @@ -108,7 +155,20 @@ export const AgentProvider = ({

const stamp = `${Date.now()}`;
setMessages((current) => [
...current,
// Only one turn is ever in flight, and the timer below resolves the
// new one by id — so a turn sent over a pending one would leave the
// earlier reply spinning forever. Close it out instead.
...current.map((message) =>
message.isPending
? {
...message,
isPending: false,
blocks: [
{ type: 'text' as const, html: '<p>Interrupted.</p>' },
],
}
: message,
),
{
id: `${stamp}-user`,
role: 'user',
Expand All @@ -123,7 +183,7 @@ export const AgentProvider = ({
},
]);

setWorking({ label: label ?? text, targetId });
setWorking({ label: label ?? text, targetId, startedAt: Date.now() });
setActivity((current) => [
{
id: `${Date.now()}`,
Expand All @@ -136,6 +196,7 @@ export const AgentProvider = ({

clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
workingRef.current = false;
setWorking(null);
setMessages((current) =>
current.map((message) =>
Expand All @@ -159,50 +220,198 @@ export const AgentProvider = ({
...current,
]);
onComplete?.();
// Drain the queue the way Claude Code does: the next queued prompt
// becomes a real turn only once the previous one has resolved.
setQueuedCommands((current) => {
const [next, ...rest] = current;

if (next) {
setTimeout(() => startRunRef.current?.(next.args), 0);
}

return rest;
});
}, workDurationMs);
},
[displayToast, interest, isDemo, sendCommand],
);

useEffect(() => {
startRunRef.current = startRun;
}, [startRun]);

const runCommand = useCallback(
(args: RunCommandArgs) => {
if (workingRef.current) {
setQueuedCommands((current) => [
...current,
{ id: `${Date.now()}-${current.length}`, args },
]);
return;
}

startRun(args);
},
[startRun],
);

const removeQueuedCommand = useCallback(
(queuedId: string) =>
setQueuedCommands((current) =>
current.filter((command) => command.id !== queuedId),
),
[],
);

// Interrupts the in-flight run the way Enter-then-Stop works in a terminal
// agent: the pending turn resolves into a visible "stopped" note rather than
// vanishing, so the transcript still reads as a record of what happened.
const stopCommand = useCallback(() => {
clearTimeout(timeoutRef.current);
workingRef.current = false;
setWorking(null);
// Stop is a brake on everything: restarting queued prompts after an
// explicit stop would resume work the user just refused.
setQueuedCommands([]);
setMessages((current) =>
current.map((message) =>
message.isPending
? {
...message,
isPending: false,
at: new Date().toISOString(),
blocks: [{ type: 'text', html: '<p>Stopped.</p>' }],
}
: message,
),
);
setActivity((current) => [
{
id: `${Date.now()}-stopped`,
at: new Date().toISOString(),
kind: 'command',
text: 'You stopped the run',
},
...current,
]);
}, []);

const openContentTarget = useCallback((target: AgentContentTarget) => {
const targetId = contentTargetId(target);

setContent(({ items }) => ({
items: items.some((item) => contentTargetId(item) === targetId)
? items
: [...items, target],
activeId: targetId,
}));
}, []);

const focusContent = useCallback(
(targetId: string) =>
setContent(({ items }) => ({ items, activeId: targetId })),
[],
);

const closeContent = useCallback((targetId: string) => {
setContent(({ items, activeId }) => {
const index = items.findIndex(
(item) => contentTargetId(item) === targetId,
);

if (index < 0) {
return { items, activeId };
}

const next = items.filter((_, position) => position !== index);
// Focus the tab that slid into the closed one's slot, falling back to the
// new last tab when the closed one was rightmost.
const successor = next[index] ?? next[next.length - 1];

return {
items: next,
activeId:
activeId === targetId && successor
? contentTargetId(successor)
: activeId,
};
});
}, []);

const closeAllContent = useCallback(() => setContent({ items: [] }), []);

const update = useCallback(
(data: UpdateInterestInput) => {
if (data.status) {
setStatusOverride(data.status);

// Switching the agent off stops it: leaving a run in flight would
// contradict the control the user just used.
if (data.status !== UserInterestStatus.Active && working) {
stopCommand();
}
}

if (isDemo || !interest) {
return;
}

updateInterest(data).catch(() => undefined);
},
[interest, isDemo, updateInterest],
[interest, isDemo, stopCommand, updateInterest, working],
);

const value = useMemo<AgentContextValue>(
() => ({
id,
interest,
status,
isDemo,
isWorking: !!working,
workingLabel: working?.label,
workingSince: working?.startedAt,
isTargetWorking: (targetId) => working?.targetId === targetId,
runCommand,
stopCommand,
queuedCommands: queuedCommands.map(({ id: queuedId, args }) => ({
id: queuedId,
text: args.text,
})),
removeQueuedCommand,
update,
isUpdating,
activity,
messages,
isSettingsOpen,
setSettingsOpen,
activeContent,
setActiveContent,
openContent: content.items,
activeContentId: content.activeId,
activeContent: content.items.find(
(item) => contentTargetId(item) === content.activeId,
),
openContentTarget,
focusContent,
closeContent,
closeAllContent,
}),
[
activeContent,
closeAllContent,
closeContent,
content,
focusContent,
openContentTarget,
activity,
messages,
id,
interest,
isDemo,
isSettingsOpen,
isUpdating,
queuedCommands,
removeQueuedCommand,
runCommand,
status,
stopCommand,
update,
working,
],
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/features/interests/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type AgentMessage = {
blocks?: AgentBlock[];
isPending?: boolean;
isScheduled?: boolean;
isError?: boolean;
/** The command a failed turn re-sends when the reader hits Retry. */
retryText?: string;
};

const minutesAgo = (minutes: number) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ActivityRow = ({ item }: { item: AgentActivityItem }): ReactElement => (
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-8 bg-surface-float text-text-tertiary">
{kindIcon[item.kind]}
</span>
<FlexCol className="gap-0.5">
<FlexCol className="min-w-0 flex-1 gap-0.5">
<Typography type={TypographyType.Callout}>{item.text}</Typography>
<Typography
type={TypographyType.Caption1}
Expand Down
Loading
Loading