Skip to content
Open
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
Expand Up @@ -266,6 +266,7 @@ export function createMobileAgentSessionManager({
isPreparingAsync: Boolean(rs && !rs.preparedAt),
prompt: rs?.prompt ?? null,
initialMessageId: rs?.initialMessageId ?? null,
associatedPr: sessionResult.associatedPr,
};
},
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const config: Config = {
],
modulePathIgnorePatterns: ['<rootDir>/../../.worktrees/'],
transformIgnorePatterns: [
'node_modules/.pnpm/(?!(@octokit|universal-user-agent|before-after-hook|bottleneck|p-limit|yocto-queue))',
'node_modules/.pnpm/(?!(@octokit|universal-user-agent|universal-github-app-jwt|before-after-hook|bottleneck|p-limit|yocto-queue))',
],

// Parallel execution configuration
Expand Down
73 changes: 63 additions & 10 deletions apps/web/src/components/cloud-agent-next/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,33 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ExternalLink, MoreHorizontal } from 'lucide-react';
import { ExternalLink, Loader2, MoreHorizontal, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { SessionInfoDialog } from './SessionInfoDialog';
import { SessionActionsDialog } from './SessionActionsDialog';
import { SoundToggleButton } from '@/components/shared/SoundToggleButton';
import { FeedbackDialog } from './FeedbackDialog';
import { buildRepoBrowseUrl, detectGitPlatform } from './utils/git-utils';
import { PrStateBadge } from './PrStateBadge';
import { resolveGithubLink, type AssociatedPr } from './utils/github-pr-link';

function extractTrpcErrorCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('data' in error)) return undefined;
const { data } = error;
if (typeof data !== 'object' || data === null || !('code' in data)) return undefined;
const { code } = data;
return typeof code === 'string' ? code : undefined;
}

function formatRefreshPrError(error: unknown): string {
const code = extractTrpcErrorCode(error);
if (code === 'TOO_MANY_REQUESTS') {
return 'Refreshed too recently. Please wait a few seconds and try again.';
}
if (code === 'BAD_REQUEST') {
return 'This session has no GitHub branch to look up.';
}
return 'Failed to refresh PR info.';
}

type ChatHeaderProps = {
cloudAgentSessionId: string;
Expand All @@ -29,6 +50,14 @@ type ChatHeaderProps = {
soundEnabled?: boolean;
onToggleSound?: () => void;
sessionTitle?: string;
associatedPr?: AssociatedPr | null;
/**
* When `kiloSessionId` is present and the session has a git branch,
* the caller can pass a refresh handler to enable the
* "Refresh PR info" menu item.
*/
onRefreshPr?: () => Promise<void>;
isRefreshingPr?: boolean;
};

export function ChatHeader({
Expand All @@ -44,15 +73,23 @@ export function ChatHeader({
kiloSessionId,
organizationId,
sessionTitle,
associatedPr,
onRefreshPr,
isRefreshingPr = false,
}: ChatHeaderProps) {
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [showActionsDialog, setShowActionsDialog] = useState(false);

const browseUrl = buildRepoBrowseUrl(gitUrl);
const repoUrl =
browseUrl && branch && detectGitPlatform(gitUrl) === 'github'
? `${browseUrl}/compare/${branch}?expand=1`
: browseUrl;
const githubLink = resolveGithubLink({ gitUrl, branch, associatedPr });

const handleRefreshPr = async () => {
if (!onRefreshPr || isRefreshingPr) return;
try {
await onRefreshPr();
} catch (error) {
toast.error(formatRefreshPrError(error));
}
};

return (
<>
Expand All @@ -64,6 +101,7 @@ export function ChatHeader({
model={model}
modelDisplayName={modelDisplayName}
cost={totalCost * 1_000_000}
associatedPr={associatedPr ?? null}
/>
<SessionActionsDialog
open={showActionsDialog}
Expand All @@ -86,14 +124,29 @@ export function ChatHeader({
<DropdownMenuItem onClick={() => setShowActionsDialog(true)}>
Share or Fork
</DropdownMenuItem>
{repoUrl && (
{githubLink.kind !== 'none' && (
<DropdownMenuItem asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<a href={githubLink.href} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open in GitHub
<span className="flex-1">{githubLink.label}</span>
{githubLink.kind === 'pr' && (
<span className="ml-2">
<PrStateBadge state={githubLink.prState} />
</span>
)}
</a>
</DropdownMenuItem>
)}
{onRefreshPr && (
<DropdownMenuItem disabled={isRefreshingPr} onClick={() => void handleRefreshPr()}>
{isRefreshingPr ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Refresh PR info
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowInfoDialog(true)}>
Session Info
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi
isPreparingAsync: Boolean(rs && !rs.preparedAt),
prompt: rs?.prompt ?? null,
initialMessageId: rs?.initialMessageId ?? null,
associatedPr: sessionResult.associatedPr,
};
},

Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc/utils';
import { ArrowDown, GitBranch } from 'lucide-react';
import { detectGitPlatform } from './utils/git-utils';

import type { KiloSessionId } from '@/lib/cloud-agent-sdk';
import { useManager } from './CloudAgentProvider';
Expand Down Expand Up @@ -107,6 +108,9 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
const { mutateAsync: orgUploadUrl } = useMutation(
trpc.organizations.cloudAgentNext.getImageUploadUrl.mutationOptions()
);
const { mutateAsync: refreshAssociatedPrMutation, isPending: isRefreshingPr } = useMutation(
trpc.cliSessionsV2.refreshAssociatedPullRequest.mutationOptions()
);
// URL-driven session switching
const sessionIdFromParams = searchParams?.get('sessionId');
useEffect(() => {
Expand Down Expand Up @@ -136,6 +140,15 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
const fetchedSessionData = useAtomValue(manager.atoms.fetchedSessionData);

const setSessionConfig = useSetAtom(manager.atoms.sessionConfig);
const setFetchedSessionData = useSetAtom(manager.atoms.fetchedSessionData);

// Keep a ref on the latest fetched session data so async handlers can read
// the current value rather than a closed-over snapshot that may be stale by
// the time an awaited request resolves.
const fetchedSessionDataRef = useRef(fetchedSessionData);
useEffect(() => {
fetchedSessionDataRef.current = fetchedSessionData;
}, [fetchedSessionData]);

const [imageMessageUuid, setImageMessageUuid] = useState(() => crypto.randomUUID());

Expand Down Expand Up @@ -383,12 +396,39 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
? 'Wrapping up…'
: 'Ask anything…';

const handleRefreshPr = useCallback(async () => {
if (!sessionIdFromParams) return;
const requestedSessionId = sessionIdFromParams;
const result = await refreshAssociatedPrMutation({ sessionId: requestedSessionId });
// Patch the fetched session data in-place so the UI reflects the refreshed
// PR immediately without a round-trip through getWithRuntimeState. Read
// the latest value via a ref and compare against the session id captured
// at call time so a session switch mid-request does not clobber the
// newly-active session with a stale snapshot.
const latest = fetchedSessionDataRef.current;
if (!latest || latest.kiloSessionId !== requestedSessionId) return;
setFetchedSessionData({ ...latest, associatedPr: result.associatedPr });
}, [sessionIdFromParams, refreshAssociatedPrMutation, setFetchedSessionData]);

// Only expose the "Refresh PR info" action for sessions where the server can
// actually look up a PR — a persisted session with a GitHub URL + branch.
// Non-GitHub URLs would otherwise produce a BAD_REQUEST toast on every click.
const canRefreshPr =
Boolean(sessionIdFromParams) &&
Boolean(fetchedSessionData?.gitBranch) &&
detectGitPlatform(fetchedSessionData?.gitUrl) === 'github';

const sessionActions = (
<ChatHeader
cloudAgentSessionId={sessionId ?? 'Starting session…'}
kiloSessionId={sessionIdFromParams ?? undefined}
organizationId={organizationId}
repository={sessionConfig?.repository ?? ''}
branch={fetchedSessionData?.gitBranch ?? undefined}
gitUrl={fetchedSessionData?.gitUrl}
associatedPr={fetchedSessionData?.associatedPr ?? null}
onRefreshPr={canRefreshPr ? handleRefreshPr : undefined}
isRefreshingPr={isRefreshingPr}
model={sessionConfig?.model}
modelDisplayName={modelDisplayName}
totalCost={totalCost}
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/cloud-agent-next/PrStateBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import type { PrBadgeState } from './utils/github-pr-link';

const STYLES: Record<PrBadgeState, string> = {
open: 'bg-emerald-500/20 text-emerald-400',
merged: 'bg-purple-500/20 text-purple-400',
closed: 'bg-zinc-500/20 text-zinc-400',
};

const LABELS: Record<PrBadgeState, string> = {
open: 'open',
merged: 'merged',
closed: 'closed',
};

export function PrStateBadge({ state }: { state: PrBadgeState }) {
return (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded px-2 py-0.5 text-xs font-medium ${STYLES[state]}`}
>
{LABELS[state]}
</span>
);
}
40 changes: 39 additions & 1 deletion apps/web/src/components/cloud-agent-next/SessionInfoDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Share2 } from 'lucide-react';
import { ExternalLink, Share2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { ShareSessionDialog } from './ShareSessionDialog';
import { formatShortModelName } from '@/lib/format-model-name';
import { PrStateBadge } from './PrStateBadge';
import { normalizePrBadgeState, truncatePrTitle, type AssociatedPr } from './utils/github-pr-link';

type SessionInfoDialogProps = {
open: boolean;
Expand All @@ -22,6 +25,7 @@ type SessionInfoDialogProps = {
model: string;
modelDisplayName?: string;
cost: number; // in microdollars
associatedPr?: AssociatedPr | null;
};

export function SessionInfoDialog({
Expand All @@ -32,6 +36,7 @@ export function SessionInfoDialog({
modelDisplayName,
cost,
kiloSessionId,
associatedPr,
}: SessionInfoDialogProps) {
const [showShareDialog, setShowShareDialog] = useState(false);

Expand Down Expand Up @@ -87,9 +92,42 @@ export function SessionInfoDialog({
${costInDollars.toFixed(4)}
</div>
</div>

<PullRequestRow associatedPr={associatedPr ?? null} />
</div>
</DialogContent>
</Dialog>
</>
);
}

function PullRequestRow({ associatedPr }: { associatedPr: AssociatedPr | null }) {
return (
<div>
<label className="text-muted-foreground mb-2 block text-sm font-medium">Pull Request</label>
{associatedPr ? (
<>
<a
href={associatedPr.url}
target="_blank"
rel="noopener noreferrer"
className="bg-muted hover:bg-muted/70 flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors"
>
<span className="font-mono text-xs">#{associatedPr.number}</span>
<span className="flex-1 truncate">{truncatePrTitle(associatedPr.title)}</span>
<PrStateBadge state={normalizePrBadgeState(associatedPr.state)} />
<ExternalLink className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
</a>
<p className="text-muted-foreground mt-1 text-xs">
Last checked{' '}
{formatDistanceToNow(new Date(associatedPr.lastSyncedAt), { addSuffix: true })}
</p>
</>
) : (
<div className="bg-muted text-muted-foreground rounded-md px-3 py-2 text-sm">
No PR associated with this branch
</div>
)}
</div>
);
}
Loading
Loading