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
22 changes: 18 additions & 4 deletions apps/web/src/app/(app)/gastown/onboarding/OnboardingStepRepo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import { useTRPC } from '@/lib/trpc/utils';
import { useUser } from '@/hooks/useUser';
import { RepositoryCombobox, type RepositoryOption } from '@/components/shared/RepositoryCombobox';
Expand Down Expand Up @@ -63,11 +65,23 @@ export function OnboardingStepRepo() {
const githubAppName = process.env.NEXT_PUBLIC_GITHUB_APP_NAME || 'KiloConnect';

const handleInstallGithub = useCallback(() => {
const installState = orgId ? `org_${orgId}` : `user_${user?.id}`;
const installUrl = `https://github.com/apps/${githubAppName}/installations/new?state=${installState}`;
window.open(installUrl, '_blank', 'noopener');
const owner = orgId ? `org_${orgId}` : `user_${user?.id}`;
const returnPath = `/gastown/onboarding?step=repo${orgId ? `&orgId=${orgId}` : ''}`;
const state = `${owner}|return=${encodeURIComponent(returnPath)}`;
const installUrl = `https://github.com/apps/${githubAppName}/installations/new?state=${encodeURIComponent(state)}`;
window.location.href = installUrl;
}, [orgId, user?.id, githubAppName]);

const githubInstallParam = useSearchParams().get('github_install');
const { refetch: refetchGithubRepos } = githubReposQuery;

useEffect(() => {
if (githubInstallParam === 'success') {
refetchGithubRepos();
toast.success('GitHub app installed. Select a repo to continue.');
}
}, [githubInstallParam, refetchGithubRepos]);

const handleRepoSelect = useCallback(
(fullName: string) => {
setSelectedRepoFullName(fullName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const STEPS = [

type StepKey = (typeof STEPS)[number]['key'];

const VALID_STEP_KEYS = new Set<string>(STEPS.map(s => s.key));

function StepIndicator({ currentIndex }: { currentIndex: number }) {
return (
<div className="flex items-center justify-center gap-0">
Expand Down Expand Up @@ -146,7 +148,16 @@ function CancelButton() {
function WizardContent() {
const searchParams = useSearchParams();
const orgId = searchParams.get('orgId');
const [currentStepKey, setCurrentStepKey] = useState<StepKey>('name');

const initialStep: StepKey = (() => {
const stepParam = searchParams.get('step');
if (stepParam && VALID_STEP_KEYS.has(stepParam)) {
return stepParam as StepKey;
}
return 'name';
})();

const [currentStepKey, setCurrentStepKey] = useState<StepKey>(initialStep);

const currentIndex = STEPS.findIndex(s => s.key === currentStepKey);

Expand Down
37 changes: 21 additions & 16 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
IntegrationPermissions,
Owner,
} from '@/lib/integrations/core/types';
import { parseStateReturn } from '@/lib/integrations/validate-return-path';
import { captureException, captureMessage } from '@sentry/nextjs';

/**
Expand All @@ -40,23 +41,25 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const installationId = searchParams.get('installation_id') ?? '';
const setupAction = searchParams.get('setup_action');
const state = searchParams.get('state'); // Contains owner info (org_ID or user_ID)
const rawState = searchParams.get('state');

// 3. Parse owner from state (with optional |return=<path> suffix)
const { ownerToken, returnTo } = parseStateReturn(rawState);

// 3. Parse owner from state
let owner: Owner;
let ownerId: string;

if (state?.startsWith('org_')) {
ownerId = state.replace('org_', '');
if (ownerToken.startsWith('org_')) {
ownerId = ownerToken.slice(4);
owner = { type: 'org', id: ownerId };
} else if (state?.startsWith('user_')) {
ownerId = state.replace('user_', '');
} else if (ownerToken.startsWith('user_')) {
ownerId = ownerToken.slice(5);
owner = { type: 'user', id: ownerId };
} else {
captureMessage('GitHub callback missing or invalid owner in state', {
level: 'warning',
tags: { endpoint: 'github/callback', source: 'github_app_installation' },
extra: { installationId, state, allParams: Object.fromEntries(searchParams.entries()) },
extra: { installationId, rawState, allParams: Object.fromEntries(searchParams.entries()) },
});
return NextResponse.redirect(new URL('/', request.url));
}
Expand Down Expand Up @@ -172,7 +175,7 @@ export async function GET(request: NextRequest) {
captureMessage('GitHub callback missing installation_id', {
level: 'warning',
tags: { endpoint: 'github/callback', source: 'github_app_installation' },
extra: { setupAction, state, allParams: Object.fromEntries(searchParams.entries()) },
extra: { setupAction, rawState, allParams: Object.fromEntries(searchParams.entries()) },
});

const redirectPath =
Expand Down Expand Up @@ -291,8 +294,9 @@ export async function GET(request: NextRequest) {
}

// 9. Redirect to success page
const successPath =
owner.type === 'org'
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}github_install=success`
: owner.type === 'org'
? `/organizations/${owner.id}/integrations/github?success=installed`
: `/integrations/github?success=installed`;

Expand All @@ -302,7 +306,7 @@ export async function GET(request: NextRequest) {

// Capture error to Sentry with context for debugging
const searchParams = request.nextUrl.searchParams;
const state = searchParams.get('state');
const rawState = searchParams.get('state');

captureException(error, {
tags: {
Expand All @@ -312,17 +316,18 @@ export async function GET(request: NextRequest) {
extra: {
installationId: searchParams.get('installation_id'),
setupAction: searchParams.get('setup_action'),
state,
rawState,
},
});

// Determine redirect path based on state parameter
const { ownerToken: errorOwnerToken } = parseStateReturn(rawState);

let redirectPath = '/?error=installation_failed';

if (state?.startsWith('org_')) {
const orgId = state.replace('org_', '');
if (errorOwnerToken.startsWith('org_')) {
const orgId = errorOwnerToken.slice(4);
redirectPath = `/organizations/${orgId}/integrations/github?error=installation_failed`;
} else if (state?.startsWith('user_')) {
} else if (errorOwnerToken.startsWith('user_')) {
redirectPath = `/integrations/github?error=installation_failed`;
}

Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/gastown/MayorChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,22 @@ export function MayorChat({ townId }: MayorChatProps) {
// Eagerly ensure mayor agent + container on mount
const ensureMayor = useMutation(
trpc.gastown.ensureMayor.mutationOptions({
onSuccess: () => {
onSuccess: data => {
queryClient.setQueryData(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({
...(old ?? {}),
configured: true,
townId,
session: {
...(old?.session ?? {}),
agentId: data.agentId,
sessionId: data.agentId,
status: data.sessionStatus,
lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(),
},
})
);
void queryClient.invalidateQueries({
queryKey: trpc.gastown.getMayorStatus.queryKey(),
});
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/gastown/TerminalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,22 @@ function MayorTerminalPane({ townId, collapsed }: { townId: string; collapsed: b

const ensureMayor = useMutation(
trpc.gastown.ensureMayor.mutationOptions({
onSuccess: () => {
onSuccess: data => {
queryClient.setQueryData(
trpc.gastown.getMayorStatus.queryKey({ townId }),
(old: { configured?: boolean; townId?: string; session?: { agentId?: string; sessionId?: string; status?: string; lastActivityAt?: string } } | undefined) => ({
...(old ?? {}),
configured: true,
townId,
session: {
...(old?.session ?? {}),
agentId: data.agentId,
sessionId: data.agentId,
status: data.sessionStatus,
lastActivityAt: old?.session?.lastActivityAt ?? new Date().toISOString(),
},
})
);
void queryClient.invalidateQueries({
queryKey: trpc.gastown.getMayorStatus.queryKey(),
});
Expand Down
87 changes: 62 additions & 25 deletions apps/web/src/components/gastown/useXtermPty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ import type { Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';

/**
* xterm.js doesn't support the Kitty keyboard protocol — all Enter
* variants are encoded as bare `\r`. The remote TUI enables Kitty
* protocol (`\x1b[>5u`) but xterm.js ignores it.
* Custom key handler for the xterm.js terminal.
*
* This handler intercepts modified Enter keys and sends the correct
* Kitty CSI u escape sequences directly over the WebSocket:
* - Shift+Enter → `\x1b[13;2u`
* - Alt+Enter → `\x1b[13;3u`
* - Ctrl+Enter → `\x1b[13;5u`
* 1. Paste: Ctrl+V / Ctrl+Shift+V on Windows/Linux.
* xterm.js's default handler treats Ctrl+V as a control byte (0x16)
* and calls preventDefault(), suppressing the browser's native paste.
* macOS Cmd+V is unaffected — xterm.js lets it through to the
* browser's native `paste` event on the helper textarea.
*
* Both keydown and keyup events are suppressed for modified Enter to
* prevent xterm from also sending its default `\r`.
* 2. Kitty Enter: xterm.js doesn't support the Kitty keyboard protocol —
* all Enter variants are encoded as bare `\r`. The remote TUI enables
* Kitty protocol (`\x1b[>5u`) but xterm.js ignores it. This handler
* intercepts modified Enter keys and sends the correct CSI u sequences
* directly over the WebSocket.
*
* NOTE: attachCustomKeyEventHandler accepts only ONE handler — each call
* replaces the previous one. This is the sole call site.
*/
export function attachKittyEnterHandler(term: Terminal, wsRef?: React.RefObject<WebSocket | null>) {
export function attachCustomKeys(term: Terminal, wsRef?: React.RefObject<WebSocket | null>) {
function send(seq: string) {
if (wsRef?.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(seq);
Expand All @@ -30,23 +34,38 @@ export function attachKittyEnterHandler(term: Terminal, wsRef?: React.RefObject<
}

term.attachCustomKeyEventHandler(ev => {
// Block both keydown and keyup for modified Enter to prevent xterm
// from sending its default \r on either event phase.
if (ev.type !== 'keydown') {
// For Kitty Enter: suppress keyup too so xterm doesn't send \r.
const isModifiedEnter =
ev.key === 'Enter' && (ev.shiftKey || ev.altKey || ev.ctrlKey) && !ev.metaKey;
return !isModifiedEnter;
}

// ── Paste: Ctrl+V or Ctrl+Shift+V on Windows/Linux ──────────────
const isPaste =
ev.ctrlKey &&
!ev.metaKey &&
!ev.altKey &&
(ev.key === 'v' || ev.key === 'V');
if (isPaste) {
ev.preventDefault();
void navigator.clipboard
.readText()
.then(text => {
if (text) term.paste(text);
})
.catch(() => {});
return false;
}

// ── Kitty Enter ──────────────────────────────────────────────────
const isModifiedEnter =
ev.key === 'Enter' && (ev.shiftKey || ev.altKey || ev.ctrlKey) && !ev.metaKey;

if (!isModifiedEnter) return true;

// Only send the sequence on keydown, but suppress keyup too
if (ev.type !== 'keydown') return false;

if (ev.shiftKey && !ev.ctrlKey && !ev.altKey) {
send('\x1b[13;2u');
} else if (ev.altKey && !ev.ctrlKey && !ev.shiftKey) {
send('\x1b[13;3u');
} else if (ev.ctrlKey && !ev.shiftKey && !ev.altKey) {
send('\x1b[13;5u');
}
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey) send('\x1b[13;2u');
else if (ev.altKey && !ev.ctrlKey && !ev.shiftKey) send('\x1b[13;3u');
else if (ev.ctrlKey && !ev.shiftKey && !ev.altKey) send('\x1b[13;5u');
return false;
});
}
Expand Down Expand Up @@ -118,6 +137,7 @@ export function useXtermPty({
const reconnectAttemptsRef = useRef(0);
const intentionalCloseRef = useRef(false);
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);

const updateStatus = useCallback(
(s: string) => {
Expand Down Expand Up @@ -329,7 +349,20 @@ export function useXtermPty({
term.loadAddon(webLinksAddon);
term.loadAddon(clipboardAddon);
term.open(container);
attachKittyEnterHandler(term, wsRef);
attachCustomKeys(term, wsRef);

const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
void navigator.clipboard
.readText()
.then(text => {
if (text) term.paste(text);
})
.catch(() => {});
};
contextMenuHandlerRef.current = onContextMenu;
container.addEventListener('contextmenu', onContextMenu);

fitAddon.fit();

xtermRef.current = term;
Expand Down Expand Up @@ -408,6 +441,10 @@ export function useXtermPty({
resizeTimerRef.current = null;
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
if (terminalRef.current && contextMenuHandlerRef.current) {
terminalRef.current.removeEventListener('contextmenu', contextMenuHandlerRef.current);
}
contextMenuHandlerRef.current = null;
wsRef.current?.close(1000, 'Terminal unmount');
wsRef.current = null;
xtermRef.current?.dispose();
Expand Down
Loading