Skip to content
Merged
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
44 changes: 21 additions & 23 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ import { pickFolderNative } from "./nativeFolderPicker.ts";
import { GitManager } from "./git/Services/GitManager.ts";
import { TerminalManager } from "./terminal/Services/Manager.ts";
import { Keybindings } from "./keybindings";
import { clearWorkspaceIndexCache, listWorkspaceDirectory, searchWorkspaceEntries } from "./workspaceEntries";
import {
clearWorkspaceIndexCache,
listWorkspaceDirectory,
searchWorkspaceEntries,
} from "./workspaceEntries";
import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
Expand Down Expand Up @@ -853,28 +857,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<

let fileTreeDebounceTimer: ReturnType<typeof setTimeout> | null = null;

const fileTreeWatcher = fs.watch(
cwd,
{ recursive: true },
(_eventType, filename) => {
if (!filename) return;

// Ignore changes inside noisy directories
const normalized = String(filename).replaceAll("\\", "/");
const firstSegment = normalized.split("/")[0];
if (firstSegment && IGNORED_WATCHER_DIRS.has(firstSegment)) return;

// Debounce rapid consecutive changes into a single push
if (fileTreeDebounceTimer) clearTimeout(fileTreeDebounceTimer);
fileTreeDebounceTimer = setTimeout(() => {
fileTreeDebounceTimer = null;
clearWorkspaceIndexCache(cwd);
void Effect.runPromise(
pushBus.publishAll(WS_CHANNELS.projectFileTreeChanged, { cwd }),
);
}, FILE_TREE_DEBOUNCE_MS);
},
);
const fileTreeWatcher = fs.watch(cwd, { recursive: true }, (_eventType, filename) => {
if (!filename) return;

// Ignore changes inside noisy directories
const normalized = String(filename).replaceAll("\\", "/");
const firstSegment = normalized.split("/")[0];
if (firstSegment && IGNORED_WATCHER_DIRS.has(firstSegment)) return;

// Debounce rapid consecutive changes into a single push
if (fileTreeDebounceTimer) clearTimeout(fileTreeDebounceTimer);
fileTreeDebounceTimer = setTimeout(() => {
fileTreeDebounceTimer = null;
clearWorkspaceIndexCache(cwd);
void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.projectFileTreeChanged, { cwd }));
}, FILE_TREE_DEBOUNCE_MS);
});

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5169,10 +5169,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
>
{pendingUserInputs.length === 0 && (
<>
<PromptEnhancer
prompt={prompt}
onEnhance={setPromptFromTraits}
/>
<PromptEnhancer prompt={prompt} onEnhance={setPromptFromTraits} />
<Button
variant="ghost"
size="icon-xs"
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,9 @@ export const ChatHeader = memo(function ChatHeader({
const threadPr =
threadBranch !== null && gitStatus?.branch === threadBranch ? (gitStatus?.pr ?? null) : null;

const openPrLink = useCallback(
(url: string) => {
void ensureNativeApi().shell.openExternal(url);
},
[],
);
const openPrLink = useCallback((url: string) => {
void ensureNativeApi().shell.openExternal(url);
}, []);

return (
<div className="flex min-w-0 flex-1 items-center gap-2">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/mobile/MobilePairingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export function MobilePairingScreen() {
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-tight">Pair this device</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Open <strong>Settings &rarr; Mobile Companion</strong> on your desktop to generate a
pairing link, then copy the link and paste it below.
Open <strong>Settings &rarr; Mobile Companion</strong> on your desktop to show a pairing
link, then copy it and paste it below.
</p>

<div className="mt-5 space-y-3">
Expand Down
75 changes: 32 additions & 43 deletions apps/web/src/components/mobile/PairingLink.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

import { resolveServerHttpOrigin } from "../../lib/runtimeBridge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";

interface PairingInfo {
pairingUrl: string;
expiresAt: string;
serverUrl: string;
}

function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}

/**
* PairingLink renders the desktop pairing link used to connect a mobile
* device. It fetches a short-lived pairing link from the server's
* `/api/pairing` endpoint and lets the user copy it directly.
* PairingLink fetches a short-lived pairing link from the server's
* `/api/pairing` endpoint and exposes it through a copy button.
*
* The link auto-refreshes when it expires so the desktop page stays usable
* without requiring a manual refresh action.
*/
export function PairingLink() {
const [pairing, setPairing] = useState<PairingInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresIn, setExpiresIn] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const refreshRequestedRef = useRef(false);

const fetchPairingLink = useCallback(async () => {
setLoading(true);
Expand Down Expand Up @@ -54,16 +62,19 @@ export function PairingLink() {
useEffect(() => {
if (!pairing?.expiresAt) {
setExpiresIn(null);
refreshRequestedRef.current = false;
return;
}

refreshRequestedRef.current = false;
const update = () => {
const remaining = Math.max(
0,
Math.floor((new Date(pairing.expiresAt).getTime() - Date.now()) / 1000),
);
setExpiresIn(remaining);
if (remaining <= 0) {
if (remaining <= 0 && !refreshRequestedRef.current) {
refreshRequestedRef.current = true;
void fetchPairingLink();
}
};
Expand All @@ -80,22 +91,18 @@ export function PairingLink() {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text in the input below.
// Clipboard access can fail in some browsers or shells; leave the button available.
}
};

const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};

return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-medium text-muted-foreground">Pair with a mobile device</h3>
<div className="flex flex-col items-center gap-4">
<h3 className="text-sm font-medium text-muted-foreground">
Pair with the OK Code mobile app
</h3>

{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-center dark:border-red-900 dark:bg-red-950">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<Button
variant="outline"
Expand All @@ -107,42 +114,24 @@ export function PairingLink() {
{loading ? "Generating..." : "Retry"}
</Button>
</div>
) : pairing?.pairingUrl ? (
) : pairing ? (
<>
<div className="space-y-2">
{expiresIn !== null ? (
<p className="text-xs text-muted-foreground">
Copy this link and open it on the mobile app or device you want to pair.
{expiresIn > 0 ? <>Expires in {formatTime(expiresIn)}</> : <>Refreshing...</>}
</p>
<Input
value={pairing.pairingUrl}
readOnly
onFocus={(event) => event.currentTarget.select()}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-2" aria-label="Pairing link actions">
) : null}
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopyLink()}>
{copied ? "Copied!" : "Copy pairing link"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void fetchPairingLink()}
disabled={loading}
>
{loading ? "Generating..." : "Refresh"}
</Button>
</div>
{expiresIn !== null ? (
<p className="text-xs text-muted-foreground">
{expiresIn > 0 ? <>Expires in {formatTime(expiresIn)}</> : <>Refreshing...</>}
</p>
) : null}
<p className="max-w-xs text-center text-[11px] leading-relaxed text-muted-foreground/70">
Copy the pairing link and paste it into the mobile app.
</p>
</>
) : loading ? (
<div className="flex min-h-24 items-center justify-center rounded-xl border border-border bg-muted">
<p className="text-sm text-muted-foreground">Generating...</p>
</div>
<p className="text-sm text-muted-foreground">Generating pairing link...</p>
) : null}
</div>
);
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ const prReviewSyncUpdatedListeners = new Set<(payload: PrReviewSyncUpdatedPayloa
const prReviewRepoConfigUpdatedListeners = new Set<
(payload: PrReviewRepoConfigUpdatedPayload) => void
>();
const projectFileTreeChangedListeners = new Set<
(payload: ProjectFileTreeChangedPayload) => void
>();
const projectFileTreeChangedListeners = new Set<(payload: ProjectFileTreeChangedPayload) => void>();
const transportStateListeners = new Set<(state: TransportState) => void>();

/**
Expand Down
4 changes: 1 addition & 3 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,7 @@ export interface NativeApi {
writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
readFile: (input: ProjectReadFileInput) => Promise<ProjectReadFileResult>;
deleteEntry: (input: ProjectDeleteEntryInput) => Promise<void>;
onFileTreeChanged: (
callback: (payload: ProjectFileTreeChangedPayload) => void,
) => () => void;
onFileTreeChanged: (callback: (payload: ProjectFileTreeChangedPayload) => void) => () => void;
};
shell: {
openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
Expand Down
Loading