Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1bc7646
chore: web: GitHub PR comment update/delete procedures (kwf app-pr-re…
iscekic Sep 23, 2026
d1e77c7
chore: mobile: comment update/delete reducers, hooks and English copy…
iscekic Sep 23, 2026
6e54419
chore: mobile: own-comment edit sheet and its route (kwf app-pr-revie…
iscekic Sep 23, 2026
6423342
chore: mobile: own-comment actions in the comment row, wiring and del…
iscekic Sep 23, 2026
176bafb
chore: mobile: translate the new comment CRUD copy into every catalog…
iscekic Sep 23, 2026
6a78d73
chore: mobile: deleting the last own conversation comment must empty …
iscekic Sep 23, 2026
2c618fe
chore: mobile: a retryable own-comment delete failure offers the Retr…
iscekic Sep 23, 2026
2cd70ed
chore: UX: With the app confirmed offline, tapping Save starts a writ…
iscekic Sep 23, 2026
d2d59dc
chore: UX: With the app confirmed offline, confirming Delete either r…
iscekic Sep 23, 2026
4ff510a
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 25, 2026
662f72d
chore(kilo-mcp): refresh catalog.json from CI
github-actions[bot] Sep 25, 2026
347499b
fix(pr-review): treat own-comment 404s as terminal and verify the PR …
iscekic Sep 25, 2026
88174f7
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 25, 2026
18904c2
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 25, 2026
2f0b46e
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 26, 2026
9bb5b63
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 28, 2026
ab2c468
Merge remote-tracking branch 'origin/main' into kwf/app-pr-review-own…
iscekic Sep 28, 2026
334cc8a
fix(web): check comment ownership on the server
iscekic Sep 28, 2026
f30051f
chore(kilo-mcp): refresh catalog.json and SKILL.md from CI
github-actions[bot] Sep 28, 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
4 changes: 2 additions & 2 deletions .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ file, for current detail.
The first path segment names an area. Search to learn an area's vocabulary:
each row's `summary` and `tags` are what the search matches.

**860 procedures** — **368 queries**, **492 mutations** — under **51 prefixes**.
**862 procedures** — **368 queries**, **494 mutations** — under **51 prefixes**.

| Prefix | Procedures | Queries | Mutations |
|---|---:|---:|---:|
Expand All @@ -50,8 +50,8 @@ each row's `summary` and `tags` are what the search matches.
| `agentProfiles` | 30 | 4 | 26 |
| `securityAgent` | 26 | 15 | 11 |
| `kiloPass` | 22 | 11 | 11 |
| `githubPrReview` | 20 | 6 | 14 |
| `githubApps` | 19 | 9 | 10 |
| `githubPrReview` | 18 | 6 | 12 |
| `cliSessionsV2` | 17 | 9 | 8 |
| `appBuilder` | 16 | 6 | 10 |
| `cliSessions` | 16 | 7 | 9 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default function PrReviewNumberLayout() {
<Stack.Screen name="index" />
<Stack.Screen name="comment-composer" options={sheetOptions} />
<Stack.Screen name="conversation-comment" options={sheetOptions} />
<Stack.Screen name="comment-edit" options={sheetOptions} />
<Stack.Screen name="review-submit" options={sheetOptions} />
<Stack.Screen name="merge" options={sheetOptions} />
<Stack.Screen name="file-navigator" options={sheetOptions} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type Href, useLocalSearchParams, useRouter } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { PrCommentEditSheet } from '@/components/pr-review/discussion/pr-comment-edit-sheet';
import { parseParam, parsePositiveIntParam } from '@/lib/route-params';

type Params = {
owner: string;
repo: string;
number: string;
commentId: string;
kind: string;
body: string;
};

// Own-comment edit formSheet, pushed by the Discussion tab's own-comment
// actions. The sheet chrome and the PendingReviewProvider hoist live in the
// `[number]` layout; this route only parses its params (a malformed deep link
// is rejected here before the sheet mounts).
export default function PrCommentEditRoute() {
const router = useRouter();
const params = useLocalSearchParams<Params>();
const owner = parseParam(params.owner);
const repo = parseParam(params.repo);
const number = parsePositiveIntParam(params.number);
const commentId = parsePositiveIntParam(params.commentId);
const kind = parseParam(params.kind, ['review', 'conversation'] as const);
const body = parseParam(params.body);

if (!owner || !repo || number === null || commentId === null || !kind || body === null) {
return <InvalidRouteState backTo={'/(app)/pr-review' as Href} />;
}

return (
<PrCommentEditSheet
owner={owner}
repo={repo}
number={number}
commentId={commentId}
kind={kind}
initialBody={body}
onDismiss={() => {
router.back();
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// Terms-gate coverage for `useComposerInlineError`. The composer mirrors the
// reply-input / submit sheets: a terms-required classification prompts the
// gate, and an `outdated` outcome is terminal (bad-request copy, no retry).
// Only the hook is under test, so no full composer mount is required.
// One further case pins the surface-specific bad-request copy: the
// `'edit-comment'` surface selects the own-comment edit copy. Only the hook is
// under test, so no full composer mount is required.

import { createElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { type MutationErrorDisplaySurface } from '@/lib/pr-review/mutation-error-display';
import { useComposerInlineError } from './composer-inline-error';

const { ensureTermsAcceptedOutcomeMock, TERMS_CHECK_RETRY, TERMS_OUTDATED } = vi.hoisted(() => ({
Expand Down Expand Up @@ -35,8 +38,16 @@ vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

let latestState: ReturnType<typeof useComposerInlineError> | null = null;

function Harness({ error, isEdit }: { error: unknown; isEdit: boolean }) {
latestState = useComposerInlineError(error, isEdit);
function Harness({
error,
isEdit,
surface,
}: {
error: unknown;
isEdit: boolean;
surface?: MutationErrorDisplaySurface;
}) {
latestState = useComposerInlineError(error, isEdit, surface);
return null;
}

Expand All @@ -54,10 +65,14 @@ async function flush(): Promise<void> {
await Promise.resolve();
}

async function mount(error: unknown, isEdit = false): Promise<TestRenderer.ReactTestRenderer> {
async function mount(
error: unknown,
isEdit = false,
surface?: MutationErrorDisplaySurface
): Promise<TestRenderer.ReactTestRenderer> {
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(createElement(Harness, { error, isEdit }));
renderer = TestRenderer.create(createElement(Harness, { error, isEdit, surface }));
await flush();
});
// eslint-disable-next-line typescript-eslint/no-unnecessary-condition
Expand Down Expand Up @@ -123,4 +138,19 @@ describe('useComposerInlineError terms gate', () => {

renderer.unmount();
});

it("selects the edit-comment bad-request copy for the 'edit-comment' surface", async () => {
const badRequest = new Error('Comment is too long');
Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } });

const renderer = await mount(badRequest, false, 'edit-comment');

expect(latestState?.inlineError).toBe(
"This comment can't be edited. It may have been deleted."
);
expect(latestState?.inlineErrorKind).toBe('bad-request');
expect(latestState?.inlineErrorIsLocal).toBe(false);

renderer.unmount();
});
});
21 changes: 17 additions & 4 deletions apps/mobile/src/components/pr-review/composer-inline-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ import { Text } from '@/components/ui/text';
import { ensureTermsAcceptedOutcome } from '@/components/pr-review/discussion/reply-input';
import { i18n } from '@/i18n';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { mutationErrorDisplay } from '@/lib/pr-review/mutation-error-display';
import {
mutationErrorDisplay,
type MutationErrorDisplaySurface,
} from '@/lib/pr-review/mutation-error-display';

export type ComposerInlineErrorKind =
| 'retryable'
| 'bad-request'
| 'forbidden'
| 'reconnect'
// The edit surface's terminal 404: the posted comment no longer exists.
// Terminal like `forbidden` — the sheet keeps Save down.
| 'not-found'
| null;

type ComposerInlineErrorProps = {
Expand Down Expand Up @@ -53,8 +59,15 @@ export function ComposerInlineError({
* error into the inline box, and clears the recoverable bad-request state
* when the body changes. The Terms gate is prompted here on a terms-required
* classification.
*
* `surface` selects the surface-specific bad-request copy; the two-argument
* call sites keep the `'composer'` behavior byte-for-byte.
*/
export function useComposerInlineError(error: unknown, isEdit: boolean) {
export function useComposerInlineError(
error: unknown,
isEdit: boolean,
surface: MutationErrorDisplaySurface = 'composer'
) {
const [inlineError, setInlineError] = useState<string | null>(null);
const [inlineErrorKind, setInlineErrorKind] = useState<ComposerInlineErrorKind>(null);
// True when `inlineError` is a local empty-body validation error (no
Expand Down Expand Up @@ -90,11 +103,11 @@ export function useComposerInlineError(error: unknown, isEdit: boolean) {
})();
return;
}
const display = mutationErrorDisplay('composer', classification, { rawError: error });
const display = mutationErrorDisplay(surface, classification, { rawError: error });
setInlineError(display.message);
setInlineErrorKind(display.kind);
setInlineErrorIsLocal(false);
}, [error, isEdit]);
}, [error, isEdit, surface]);

function clearBadRequestOnBodyEdit() {
// bad-request clears on body change; forbidden stays for the session.
Expand Down
135 changes: 134 additions & 1 deletion apps/mobile/src/components/pr-review/discussion/comment-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ vi.mock('@/lib/utils', () => ({
async function render(
comment: ReviewComment,
viewerLogin: string | null = 'bob',
extra: { readOnly?: boolean; reactionsSupported?: boolean } = {}
extra: {
readOnly?: boolean;
reactionsSupported?: boolean;
onEditComment?: () => void;
onDeleteComment?: () => void;
} = {}
): Promise<TestRenderer.ReactTestRenderer> {
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
Expand Down Expand Up @@ -346,6 +351,134 @@ describe('CommentRow overflow actions', () => {
});
});

// s4: the viewer's own comment gains Edit comment / Delete comment. The
// self-target moderation trio is dropped when those callbacks are wired (a row
// of disabled entries is a dead affordance); a read-only provider row passes
// neither callback and keeps today's menu exactly.
describe('CommentRow own-comment actions (s4)', () => {
beforeEach(() => {
alertCalls.length = 0;
showActionSheetMock.mockClear();
mutateFns.length = 0;
capturedOptions.length = 0;
});

afterEach(() => {
vi.clearAllMocks();
});

it('offers Edit comment then Delete comment on the viewer’s own comment and dispatches each once', async () => {
const onEditComment = vi.fn<() => void>();
const onDeleteComment = vi.fn<() => void>();
const renderer = await render(
makeComment({ author: { login: 'bob', avatarUrl: null } }),
'bob',
{ onEditComment, onDeleteComment }
);
openOverflow(renderer);

expect(overflowOptions()).toEqual([
'Edit comment',
'Delete comment',
'Report content',
'Cancel',
]);

// Index 0 is Edit comment; index 1 is Delete comment.
selectOverflowAction(0);
expect(onEditComment).toHaveBeenCalledTimes(1);
expect(onDeleteComment).not.toHaveBeenCalled();

selectOverflowAction(1);
expect(onDeleteComment).toHaveBeenCalledTimes(1);
expect(onEditComment).toHaveBeenCalledTimes(1);

renderer.unmount();
});

it('matches the viewer login case-insensitively for the own-comment gate', async () => {
const onEditComment = vi.fn<() => void>();
const onDeleteComment = vi.fn<() => void>();
const renderer = await render(
makeComment({ author: { login: 'Bob', avatarUrl: null } }),
'bob',
{ onEditComment, onDeleteComment }
);
openOverflow(renderer);

expect(overflowOptions()).toEqual([
'Edit comment',
'Delete comment',
'Report content',
'Cancel',
]);

renderer.unmount();
});

it('offers no edit and no delete on another author’s comment', async () => {
const onEditComment = vi.fn<() => void>();
const onDeleteComment = vi.fn<() => void>();
const renderer = await render(makeComment(), 'bob', { onEditComment, onDeleteComment });
openOverflow(renderer);

expect(overflowOptions()).toEqual(['Report content', 'Report user', 'Mute', 'Block', 'Cancel']);

// Report content is index 0 here — never a stale own-comment index.
selectOverflowAction(0);
expect(onEditComment).not.toHaveBeenCalled();
expect(onDeleteComment).not.toHaveBeenCalled();

renderer.unmount();
});

it('offers no edit and no delete to an anonymous viewer (null login)', async () => {
const onEditComment = vi.fn<() => void>();
const onDeleteComment = vi.fn<() => void>();
const renderer = await render(
makeComment({ author: { login: 'bob', avatarUrl: null } }),
null,
{ onEditComment, onDeleteComment }
);
openOverflow(renderer);

expect(overflowOptions()).toEqual(['Report content', 'Report user', 'Mute', 'Block', 'Cancel']);

selectOverflowAction(0);
expect(onEditComment).not.toHaveBeenCalled();
expect(onDeleteComment).not.toHaveBeenCalled();

renderer.unmount();
});

it('offers no edit and no delete without callbacks (provider scope), keeping today’s disabled trio', async () => {
const renderer = await render(
makeComment({ author: { login: 'bob', avatarUrl: null } }),
'bob'
);
openOverflow(renderer);

expect(overflowOptions()).toEqual(['Report content', 'Report user', 'Mute', 'Block', 'Cancel']);
expect(disabledButtonIndices()).toEqual([1, 2, 3]);

renderer.unmount();
});

it('offers no edit and no delete on a deleted-author comment (author null)', async () => {
const onEditComment = vi.fn<() => void>();
const onDeleteComment = vi.fn<() => void>();
const renderer = await render(makeComment({ author: null }), 'bob', {
onEditComment,
onDeleteComment,
});
openOverflow(renderer);

expect(overflowOptions()).toEqual(['Report content', 'Cancel']);

renderer.unmount();
});
});

// Fix 19: the four moderation `useMutation` hooks used to mount once per row
// (4N observers for N mounted rows). One provider owns them and every row
// shares that set.
Expand Down
Loading
Loading