Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ce92982
feat(security-agent): add single command-type authority with drift tests
iscekic Aug 21, 2026
25843ba
feat(notifications): add typed security lifecycle push payload
iscekic Aug 21, 2026
3e6b20f
feat(analytics): extend settled-outcome catalog for security and code…
iscekic Aug 21, 2026
4d1ae22
feat(security-agent): add bounded batch command status procedure
iscekic Aug 21, 2026
4a3056a
feat(organizations): gate GitLab OAuth replacement to billing roles
iscekic Aug 21, 2026
727190d
feat(user): expose notification producer capabilities
iscekic Aug 21, 2026
dcfe208
feat(mobile): add bounded batch command status observer
iscekic Aug 21, 2026
346dd22
feat(mobile): render patch parts with file-list summary
iscekic Aug 21, 2026
46bb543
feat(mobile): add native security audit report screen
iscekic Aug 21, 2026
2deae16
feat(mobile): persist attention items in encrypted KV
iscekic Aug 21, 2026
f43674e
feat(mobile): consume security lifecycle pushes with invalidation
iscekic Aug 21, 2026
a8baf20
feat(mobile): drive notification controls from producer capabilities
iscekic Aug 21, 2026
feb6e86
feat(security-agent): add auto-remediation approval gate with trust c…
iscekic Aug 21, 2026
c2d70ec
feat(organizations): narrow member-visible DTOs by role
iscekic Aug 21, 2026
e521079
feat(code-reviews): add paginated review memory with native screen
iscekic Aug 21, 2026
d91073a
fix(security-agent): complete approval_required reason surface
iscekic Aug 21, 2026
8575113
fix(mobile): type review-memory FlashList test mock
iscekic Aug 21, 2026
934836c
fix(code-reviews): keep array listProposals and add paginated page
iscekic Aug 21, 2026
d0cfa2f
fix(mobile): complete review-memory review fixes
iscekic Aug 21, 2026
a6d3226
fix(mobile): resolve automation-settings test lint errors
iscekic Aug 21, 2026
b3a42b5
test(mobile): wait for sibling row before unavailable assertion
iscekic Aug 21, 2026
ca2789f
feat(security-agent): settle remediation ledger rows at every termina…
iscekic Aug 21, 2026
307b126
feat(code-reviews): emit settled outcomes for code review and securit…
iscekic Aug 21, 2026
52676ea
feat(web): narrow list DTOs for findings, code reviews, and organizat…
iscekic Aug 21, 2026
a1afbdc
feat(notifications): add security lifecycle push producers
iscekic Aug 21, 2026
73013a8
test(security-agent): cover terminal launch-failure emit after retry …
iscekic Aug 21, 2026
1ee321e
feat(code-reviews): add delta repository save with webhook sync
iscekic Aug 21, 2026
be83248
feat(security-agent): add remediation progress timeline to finding de…
iscekic Aug 21, 2026
ce5b195
feat(mobile): route remediation PR buttons into native PR review
iscekic Aug 21, 2026
9282552
feat(mobile): debounce repo selection saves into deltas
iscekic Aug 21, 2026
5f25153
chore(format): run oxfmt on changed files
iscekic Aug 21, 2026
c9e9f12
fix(organizations): type member DTO narrowing without unsafe cast
iscekic Aug 21, 2026
180cf27
fix(organizations): hide copy-invite control without invite URL
iscekic Aug 21, 2026
51532f6
Merge remote-tracking branch 'origin/main' into audit-w6a-security-re…
iscekic Aug 21, 2026
03c67dd
fix(mobile): drop unused repo-selection delta export
iscekic Aug 21, 2026
65f26ba
fix(security-agent): enqueue include-existing backlog when approval t…
iscekic Aug 21, 2026
dfa8798
fix(organizations): validate Bitbucket deltas before patch save
iscekic Aug 21, 2026
f670eaa
fix(code-reviews): redact raw ids in detail and settle local cancels
iscekic Aug 21, 2026
14ffde4
fix(code-reviews): admit ledger row after review transaction commits
iscekic Aug 21, 2026
7b47ad4
fix(code-reviews): settle ledger for superseded and user-cancelled re…
iscekic Aug 21, 2026
374ed6a
fix(code-reviews): use two-argument jest.fn generic in ledger test
iscekic Aug 21, 2026
afb81fc
fix(security-agent): default missing approval flag to not required
iscekic Aug 21, 2026
c8a44e3
fix(mobile): refetch analysis on security lifecycle recovery
iscekic Aug 21, 2026
0efadca
fix(mobile): keep personal audit-report auth failures retryable
iscekic Aug 21, 2026
855c445
fix(code-reviews): label hidden check run instead of None for members
iscekic Aug 21, 2026
2a7811b
fix(mobile): default missing remediation timeline to empty
iscekic Aug 21, 2026
9107477
fix(mobile): show enable CTA while reviewer permission unresolved
iscekic Aug 21, 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
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ import {
useGitHubRepositories,
useGitLabRepositories,
useReviewConfig,
useReviewConfigCacheReader,
useSaveReviewConfig,
} from '@/lib/hooks/use-code-reviewer';
import { useRepoSelectionToggle } from '@/lib/hooks/use-code-reviewer-repo-selection';
import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls';

export default function ReposRoute() {
const { scope, platform } = useLocalSearchParams<{ scope: string; platform: ReviewerPlatform }>();
const { data } = useReviewConfig(scope, platform);
const save = useSaveReviewConfig(scope, platform);
const readConfig = useReviewConfigCacheReader(scope, platform);
const toggleRepo = useRepoSelectionToggle(scope, platform);
const capabilities = PLATFORM_CAPABILITIES[platform];
const mode = data?.repositorySelectionMode ?? 'all';
const githubRepos = useGitHubRepositories(scope, platform === 'github' && mode === 'selected');
Expand Down Expand Up @@ -110,17 +110,6 @@ export default function ReposRoute() {
save.mutate({ repositorySelectionMode: nextMode });
};

const toggleRepo = (id: number | string) => {
// Read the cache at call time, not the render-time snapshot above, so
// two rapid taps each build the next array from the latest committed
// selection instead of dropping one another.
const current = readConfig()?.selectedRepositoryIds ?? [];
const next = current.includes(id)
? current.filter(existing => existing !== id)
: [...current, id];
save.mutate({ selectedRepositoryIds: next });
};

return (
<View className="flex-1 bg-background">
<ScreenHeader title="Repositories" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useLocalSearchParams } from 'expo-router';

import { ReviewMemoryScreen } from '@/components/code-reviewer/review-memory-screen';

export default function CodeReviewerReviewMemoryRoute() {
const { scope } = useLocalSearchParams<{ scope: string }>();
return <ReviewMemoryScreen scope={scope} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useLocalSearchParams } from 'expo-router';

import { AuditReportScreen } from '@/components/security-agent/audit-report-screen';

export default function SecurityAgentAuditReportRoute() {
const { scope } = useLocalSearchParams<{ scope: string }>();
return <AuditReportScreen scope={scope} />;
}
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { useFormSheetDetents } from '@/lib/form-sheet';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useSecurityLifecycleInvalidation } from '@/lib/hooks/use-security-lifecycle-invalidation';
import { CachePersistenceMount } from '@/lib/persist/cache-persistence-mount';

/**
Expand Down Expand Up @@ -85,6 +86,7 @@ function PushRegistrationMount() {
export default function AppLayout() {
const colors = useThemeColors();
const { fullSheetDetent } = useFormSheetDetents();
useSecurityLifecycleInvalidation();

return (
<UserWebConnectionProvider>
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/components/agents/message-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ function toolPart(tool: string): Part {
};
}

function patchPart(files: string[]): Part {
return {
id: 'p4',
sessionID: 's1',
messageID: 'm1',
type: 'patch',
hash: 'abc',
files,
};
}

function assistantMessage(parts: Part[]): StoredMessage {
return {
info: {
Expand Down Expand Up @@ -119,6 +130,14 @@ describe('partRendersContent', () => {
};
expect(partRendersContent(part)).toBe(true);
});

it('returns true for a patch part with files', () => {
expect(partRendersContent(patchPart(['src/a.ts', 'src/b.ts']))).toBe(true);
});

it('returns false for a patch part with no files', () => {
expect(partRendersContent(patchPart([]))).toBe(false);
});
});

describe('messageRendersContent', () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/agents/message-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import {
isCompactionPart,
isFilePart,
isPatchPart,
isReasoningPart,
isSnapshotProgressPart,
isTextPart,
Expand Down Expand Up @@ -34,7 +35,7 @@ export function partRendersContent(part: Part): boolean {
// property of the part, not of the stream.
return shouldRenderReasoningPart(part, false);
}
return isFilePart(part) || isCompactionPart(part);
return isFilePart(part) || isCompactionPart(part) || (isPatchPart(part) && part.files.length > 0);
}

/**
Expand Down
168 changes: 167 additions & 1 deletion apps/mobile/src/components/agents/part-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { type ReasoningPart, type TextPart, type ToolPart } from '@kilocode/cloud-agent-sdk';
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/test/render-with-providers.tsx */
import {
type PatchPart,
type ReasoningPart,
type TextPart,
type ToolPart,
} from '@kilocode/cloud-agent-sdk';
import * as React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { PartRenderer } from './part-renderer';
import { ReasoningPartRenderer } from './reasoning-part-renderer';
import { TextPartRenderer } from './text-part-renderer';
import { PatchToolCardBody } from './tool-cards/patch-tool-card';
import { ToolPartRenderer } from './tool-part-renderer';

vi.mock('./child-session-section', () => ({}));
Expand All @@ -27,6 +36,21 @@ vi.mock('./text-part-renderer', () => ({
vi.mock('./tool-part-renderer', () => ({
ToolPartRenderer: () => null,
}));
// The patch part summary renders `View`/`Text`; the mounted patch-card test
// mounts the real `PatchToolCardBody` + `ToolPatchPreview` chain with only the
// leaf `DiffLine` mocked, so these module mocks keep React Native out of node.
vi.mock('react-native', () => ({ View: 'View' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/components/pr-review/diff/diff-line', () => ({ DiffLine: 'DiffLine' }));
vi.mock('@/components/ui/icons', () => ({ FileDiff: 'FileDiff' }));
vi.mock('@/components/ui/selectable-text', () => ({ SelectableText: 'SelectableText' }));
vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' }));
vi.mock('./open-part-detail-context', () => ({ useOpenPartDetail: () => undefined }));
vi.mock('./tool-card-display', () => ({
getToolDisplay: () => ({}),
toolPartHasDetails: () => false,
}));
vi.mock('./tool-cards/generic-tool-card', () => ({ GenericToolCardBody: 'GenericToolCardBody' }));

function makeReasoningPart(text: string, ended = true): ReasoningPart {
return {
Expand Down Expand Up @@ -54,6 +78,106 @@ function makeTextPart(text: string, synthetic?: boolean, ended = true): TextPart
return part;
}

function makePatchPart(files: string[]): PatchPart {
return {
id: 'p1',
sessionID: 's1',
messageID: 'm1',
type: 'patch',
hash: 'abc',
files,
};
}

const PATCH_TEXT = '*** Begin Patch\n*** Add File: src/a.ts\n+x\n*** End Patch';

function makePatchState(
tool: 'patch' | 'apply_patch',
status: ToolPart['state']['status']
): ToolPart['state'] {
const input = { patchText: PATCH_TEXT };
const states: Record<ToolPart['state']['status'], ToolPart['state']> = {
pending: { status: 'pending', input, raw: '' },
running: { status: 'running', input, time: { start: 1 } },
error: { status: 'error', input, error: 'patch failed', time: { start: 1, end: 2 } },
completed: {
status: 'completed',
input,
output: '',
title: tool,
metadata: {},
time: { start: 1, end: 2 },
},
};
return states[status];
}

function makePatchToolPart(
tool: 'patch' | 'apply_patch',
status: ToolPart['state']['status'] = 'completed'
): ToolPart {
return {
id: 'patch-1',
sessionID: 's1',
messageID: 'm1',
type: 'tool',
callID: 'call-1',
tool,
state: makePatchState(tool, status),
};
}

function findAll(
node: unknown,
predicate: (el: React.ReactElement) => boolean
): React.ReactElement[] {
const matches: React.ReactElement[] = [];
function walk(value: unknown): void {
if (value == null || typeof value === 'string' || typeof value === 'number') {
return;
}
if (Array.isArray(value)) {
for (const child of value) {
walk(child);
}
return;
}
if (React.isValidElement(value)) {
if (predicate(value)) {
matches.push(value);
}
const props = value.props as Record<string, unknown>;
if (typeof value.type === 'function') {
walk((value.type as React.FunctionComponent<unknown>)(props));
} else {
walk(props.children);
}
}
}
walk(node);
return matches;
}

function findText(root: unknown, text: string): React.ReactElement[] {
return findAll(
root,
el => el.type === 'Text' && (el.props as { children?: unknown }).children === text
);
}

async function mountPatchBody(part: ToolPart): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
await Promise.resolve();
ref.current = TestRenderer.create(React.createElement(PatchToolCardBody, { part }));
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

function makeToolPart(): ToolPart {
return {
id: 'tool-1',
Expand Down Expand Up @@ -163,3 +287,45 @@ describe('PartRenderer', () => {
expect(toolElement.props).toMatchObject({ modelOptions });
});
});

describe('PartRenderer patch part summary', () => {
it('renders the file count and paths for a patch part', () => {
const part = makePatchPart(['src/a.ts', 'src/b.ts']);
// eslint-disable-next-line new-cap
const result = PartRenderer({ part });
expect(result).not.toBeNull();
expect(findText(result, 'Updated 2 files')).toHaveLength(1);
expect(findText(result, 'src/a.ts')).toHaveLength(1);
expect(findText(result, 'src/b.ts')).toHaveLength(1);
});

it('uses the singular label for a single file', () => {
const part = makePatchPart(['src/a.ts']);
// eslint-disable-next-line new-cap
const result = PartRenderer({ part });
expect(findText(result, 'Updated 1 file')).toHaveLength(1);
});

it('returns null for a patch part with no files', () => {
const part = makePatchPart([]);
// eslint-disable-next-line new-cap
const result = PartRenderer({ part });
expect(result).toBeNull();
});
});

describe('PatchToolCardBody mounted diff lines', () => {
it.each(
(['patch', 'apply_patch'] as const).flatMap(tool =>
(['pending', 'running', 'completed', 'error'] as const).map(status => [tool, status] as const)
)
)('renders diff lines for tool %s in the %s state', async (tool, status) => {
const renderer = await mountPatchBody(makePatchToolPart(tool, status));
const diffLines = renderer.root.findAll(node => String(node.type) === 'DiffLine');
expect(diffLines).toHaveLength(1);
const errorLines = renderer.root.findAll(
node => String(node.type) === 'SelectableText' && node.props.children === 'patch failed'
);
expect(errorLines).toHaveLength(status === 'error' ? 1 : 0);
});
});
26 changes: 25 additions & 1 deletion apps/mobile/src/components/agents/part-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk';
import { View } from 'react-native';

import { Text } from '@/components/ui/text';

import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

Expand All @@ -10,6 +13,7 @@ import {
isCompactionPart,
isFilePart,
isPartStreaming,
isPatchPart,
isReasoningPart,
isTextPart,
isToolPart,
Expand Down Expand Up @@ -81,6 +85,26 @@ export function PartRenderer({
if (isCompactionPart(part)) {
return <CompactionSeparator />;
}
// step-start, step-finish, patch, snapshot, agent, retry, subtask — not rendered
// Standalone PatchPart (`type: 'patch'`) carries only file paths — no diff
// text — so the diff engine cannot apply. The web renderer renders null for
// it (apps/web/src/components/cloud-agent-next/PartRenderer.tsx:398-404).
// If OpenCode ever ships diff text on the part, render it through `DiffLine`.
if (isPatchPart(part)) {
const fileCount = part.files.length;
const summary = `Updated ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`;
return (
<MessageErrorBoundary>
<View className="my-1 gap-1">
<Text className="text-xs text-muted-foreground">{summary}</Text>
{part.files.map(file => (
<Text key={file} className="font-mono text-xs text-muted-foreground" numberOfLines={1}>
{file}
</Text>
))}
</View>
</MessageErrorBoundary>
);
}
// step-start, step-finish, snapshot, agent, retry, subtask — not rendered
return null;
}
Loading