Skip to content
Closed
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
78 changes: 78 additions & 0 deletions apps/lite/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,84 @@ behind mode conditionals — the tell is an `if (tab === ...)` guard, or a
comment explaining a special case, in code that shouldn't know that mode
exists.

# GitButler comments

GitButler supports experimental interactive comment threads between humans and multiple agents.

We use a primary agent to work and send messages and acknowledgements, and a subagent to listen for messages in active threads.

## Primary agent instructions

As the primary agent, when asked to check comments, listen for feedback, or collaborate through GitButler, it is your job to spawn the listener subagent for each project/repo you're working on, listen for messages from them, acknowledge and potentially reply to them, and act on work according to these messages.

For example:

1. Spawn subagent and listen.
2. Receive message "change font size of text" from subagent.
3. Acknowledge receipt of the message.
4. Do your work.
5. Reply again in the relevant thread.
6. Continue listening to the subagent, including in the middle of in-progress work, akin to steering.

Wait for the subagent to report that it is ready before reporting that the listener is running. While a listener is active, check for forwarded messages before responding to the human; agent status alone does not show pending messages.

While a listener is active, remain in the turn and wait for subagent messages.

Because the listener is listening on a blocking command, send replies from the primary agent directly.

### Acknowledgement & reply

Always acknowledge seen messages. You may reply to them at the same time.

```console
$ but _comment reply <thread-id> \
--client-id pi-feature-foo-uniqueidhere \
--author Pi \
--author-kind agent \
--ack-through <message-id> \
--message 'I have implemented the change you requested.'
```

```console
$ but _comment ack <thread-id> \
--client-id pi-feature-foo-uniqueidhere \
--message <message-id>
```

### Resolution

You may resolve a thread only when the work is confidently complete or the human explicitly requests it.

```console
$ but _comment archive <thread-id>
```

## Listener subagent instructions

These instructions are for the spawned subagent.

Choose one stable client ID, friendly agent name, and short, friendly title for your agentic workstream. Only the client ID needs to be unique. The agent name should be intuitive, for example "Codex" or "Claude". The title ideally mimics your harness thread title verbatim. You will additionally identify yourself as an agent.

As per the but CLI run commands from the project directory.

Before listening, report to the primary agent that you are ready and include your client ID, author, and title.

### Listening

```console
$ but _comment list --wait \
--client-id pi-feature-foo-uniqueidhere \
--author Pi \
--author-kind agent \
--title 'Implement feature foo'
```

`list --wait` returns after delivering work or timing out.

After each non-timeout delivery, acknowledge through the newest message returned, forward the thread to the primary agent, and then resume listening. Do not reply, that is the responsiblity of the primary agent.

Do not stop listening or stop repeating the command unless explicitly instructed.

# Verifying your work

Always run the specified commands **exactly** as written.
Expand Down
98 changes: 87 additions & 11 deletions apps/lite/ui/src/annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { decodeBytes } from "#ui/api/bytes.ts";
import { errorMessageForToast } from "#ui/errors.ts";
import type { FileParent } from "#ui/operands.ts";
import { Toast } from "@base-ui/react";
import type { DiffComment, DiffSide } from "@gitbutler/but-sdk";
import type { CommentMessage, CommentParticipant, DiffComment, DiffSide } from "@gitbutler/but-sdk";
import type { AnnotationSide } from "@pierre/diffs";
import { useMutation } from "@tanstack/react-query";

Expand All @@ -16,8 +16,9 @@ export type LocalAnnotation = {
id: string;
lineNumber: number;
side: AnnotationSide;
body: string;
updatedAtMs: number;
messages: Array<CommentMessage>;
agentParticipantIds: Array<string>;
agentParticipants: Array<CommentParticipant>;
};

export type LocalAnnotationsByPath = Map<string, Array<LocalAnnotation>>;
Expand Down Expand Up @@ -47,8 +48,9 @@ export const annotationsByPathForScope = (
id: comment.id,
lineNumber: comment.lineNumber,
side: diffSideToAnnotationSide(comment.side),
body: comment.payload,
updatedAtMs: comment.updatedAtMs,
messages: comment.messages,
agentParticipantIds: comment.agentParticipantIds,
agentParticipants: comment.agentParticipants,
});
byPath.set(comment.path, annotations);
}
Expand All @@ -63,16 +65,31 @@ export const useCommentCreate = () => {
mutationFn: (params: PayloadFor<"commentCreate"> & { comment: { id: string } }) =>
window.lite.commentCreate(params),
onMutate: async (input, ctx) => {
const messageId = input.comment.message.id;
if (messageId === null) throw new Error("Optimistic message requires an id");
await ctx.client.cancelQueries({ queryKey: commentsQueryOptions(input.projectId).queryKey });

const prev = ctx.client.getQueryData(commentsQueryOptions(input.projectId).queryKey);

const now = Date.now();
const { message, ...thread } = input.comment;
const appended: DiffComment = {
...input.comment,
...thread,
lineContent: "",
createdAtMs: now,
updatedAtMs: now,
messages: [
{
...message,
authorTitle: null,
mentionedClientIds: message.mentionedClientIds ?? [],
acknowledgements: [],
expectedAcknowledgementCount: message.mentionedClientIds?.length ?? 0,
id: messageId,
createdAtMs: now,
updatedAtMs: now,
},
],
agentParticipantIds: message.mentionedClientIds ?? [],
agentParticipants: [],
context: null,
};

Expand Down Expand Up @@ -101,11 +118,12 @@ export const useCommentCreate = () => {
});
};

export const useCommentUpdate = () => {
export const useCommentDraftPublish = () => {
const toastManager = Toast.useToastManager();

return useMutation({
mutationFn: (params: PayloadFor<"commentUpdate">) => window.lite.commentUpdate(params),
mutationFn: (params: PayloadFor<"commentDraftPublish">) =>
window.lite.commentDraftPublish(params),
onSettled: (_comment, _err, input, _result, ctx) =>
ctx.client.invalidateQueries({ queryKey: commentsQueryOptions(input.projectId).queryKey }),
onError: (error) => {
Expand All @@ -122,6 +140,61 @@ export const useCommentUpdate = () => {
});
};

export const useCommentReply = () => {
const toastManager = Toast.useToastManager();

return useMutation({
mutationFn: (params: PayloadFor<"commentReply"> & { message: { id: string } }) =>
window.lite.commentReply(params),
onMutate: async (input, ctx) => {
await ctx.client.cancelQueries({ queryKey: commentsQueryOptions(input.projectId).queryKey });
const prev = ctx.client.getQueryData(commentsQueryOptions(input.projectId).queryKey);
const now = Date.now();
ctx.client.setQueryData(commentsQueryOptions(input.projectId).queryKey, (comments) =>
comments?.map((comment) =>
comment.id === input.id
? {
...comment,
messages: comment.messages.concat({
...input.message,
authorTitle: null,
mentionedClientIds: input.message.mentionedClientIds ?? [],
acknowledgements: [],
expectedAcknowledgementCount: new Set([
...comment.agentParticipantIds,
...(input.message.mentionedClientIds ?? []),
]).size,
createdAtMs: now,
updatedAtMs: now,
}),
agentParticipantIds: [
...new Set([
...comment.agentParticipantIds,
...(input.message.mentionedClientIds ?? []),
]),
],
}
: comment,
),
);
return prev;
},
onSettled: (_message, _err, input, _result, ctx) =>
ctx.client.invalidateQueries({ queryKey: commentsQueryOptions(input.projectId).queryKey }),
onError: (error, input, prev, ctx) => {
if (prev) ctx.client.setQueryData(commentsQueryOptions(input.projectId).queryKey, prev);
// oxlint-disable-next-line no-console
console.error(error);
toastManager.add({
type: "error",
title: "Failed to reply to comment",
description: errorMessageForToast(error),
priority: "high",
});
},
});
};

export const useCommentArchive = () => {
const toastManager = Toast.useToastManager();

Expand Down Expand Up @@ -182,7 +255,10 @@ ${allFeedback
idx,
) => `${idx + 1}. ${feedback.path}:${feedback.annotation.lineNumber} (${feedback.annotation.side}) in ${localAnnotationRevision(feedback.fileParent)}

${feedback.annotation.body}
${feedback.annotation.messages
.filter((message) => message.payload.trim() !== "")
.map((message) => `${message.author} (${message.authorKind}): ${message.payload}`)
.join("\n\n")}
`,
)
.join("\n")}`;
7 changes: 7 additions & 0 deletions apps/lite/ui/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ export const commentsQueryOptions = (projectId: string) =>
queryFn: () => window.lite.commentsList(projectId),
});

export const commentAgentsQueryOptions = (projectId: string) =>
queryOptions({
queryKey: ["commentAgentsList", projectId],
queryFn: () => window.lite.commentAgentsList(projectId),
refetchInterval: 5_000,
});

export const commitDetailsWithLineStatsQueryOptions = ({
projectId,
...params
Expand Down
Loading
Loading