Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions apps/marketing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"shiki": "^4.0.1",
"typescript": "catalog:"
}
}
26 changes: 26 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}),
);

it.effect("compiles the project picker default shortcut", () =>
Effect.sync(() => {
const compiled = compileResolvedKeybindingRule({
key: "mod+k",
command: "chat.projectPicker",
when: "!terminalFocus",
});

assert.deepEqual(compiled, {
command: "chat.projectPicker",
shortcut: {
key: "k",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
},
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
});
}),
);

it.effect("encodes resolved plus-key shortcuts", () =>
Effect.gen(function* () {
const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+k", command: "chat.projectPicker", when: "!terminalFocus" },
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
Expand Down
144 changes: 144 additions & 0 deletions apps/web/src/components/ChatShellProjectPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { ThreadId, type ProjectId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useComposerDraftStore } from "../composerDraftStore";
import { useProjectNavigation } from "../hooks/useProjectNavigation";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { getTerminalStatusIndicator, getThreadStatusPill } from "../lib/threadStatus";
import { type ProjectPickerThreadSearchEntry } from "../lib/projectPickerSearch";
import { useStore } from "../store";
import { derivePendingApprovals } from "../session-logic";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { ProjectPickerDialog } from "./ProjectPickerDialog";

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];

function isTerminalFocused(): boolean {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return false;
if (activeElement.classList.contains("xterm-helper-textarea")) return true;
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
}

export function ChatShellProjectPicker() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
const navigate = useNavigate();
const routeThreadId = useParams({
strict: false,
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
});
const activeDraftThread = useComposerDraftStore((store) =>
routeThreadId ? store.draftThreadsByThreadId[routeThreadId] ?? null : null,
);
const { openProject } = useProjectNavigation();
const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
...serverConfigQueryOptions(),
select: (config) => config.keybindings,
});
const [open, setOpen] = useState(false);
const [focusRequestId, setFocusRequestId] = useState(0);

const activeProjectId = useMemo<ProjectId | null>(() => {
const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : null;
return activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
}, [activeDraftThread?.projectId, routeThreadId, threads]);

const threadCountByProjectId = useMemo(() => {
const counts = new Map<ProjectId, number>();
for (const project of projects) {
counts.set(project.id, 0);
}
for (const thread of threads) {
counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1);
}
return counts;
}, [projects, threads]);
const projectById = useMemo(
() => new Map(projects.map((project) => [project.id, project] as const)),
[projects],
);
const threadEntries = useMemo<ProjectPickerThreadSearchEntry[]>(
() =>
threads
.toSorted((left, right) => {
const byDate = new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
if (byDate !== 0) return byDate;
return right.id.localeCompare(left.id);
})
.map((thread) => ({
thread,
project: projectById.get(thread.projectId) ?? null,
})),
[projectById, threads],
);
const threadIndicatorsByThreadId = useMemo(() => {
const indicators = new Map<
ThreadId,
{
threadStatus: ReturnType<typeof getThreadStatusPill>;
terminalStatus: ReturnType<typeof getTerminalStatusIndicator>;
}
>();
for (const thread of threads) {
indicators.set(thread.id, {
threadStatus: getThreadStatusPill(thread, derivePendingApprovals(thread.activities).length > 0),
terminalStatus: getTerminalStatusIndicator(
selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
),
});
}
return indicators;
}, [terminalStateByThreadId, threads]);

const openPicker = useCallback(() => {
setOpen(true);
setFocusRequestId((current) => current + 1);
}, []);

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
terminalOpen: false,
},
});
if (command !== "chat.projectPicker") return;
event.preventDefault();
event.stopPropagation();
openPicker();
};

window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [keybindings, openPicker]);

return (
<ProjectPickerDialog
open={open}
onOpenChange={setOpen}
projects={projects}
threads={threadEntries}
activeProjectId={activeProjectId}
activeThreadId={routeThreadId}
threadCountByProjectId={threadCountByProjectId}
threadIndicatorsByThreadId={threadIndicatorsByThreadId}
onSelectProject={openProject}
onSelectThread={async (threadId) => {
await navigate({
to: "/$threadId",
params: { threadId },
});
}}
shortcutLabel={shortcutLabelForCommand(keybindings, "chat.projectPicker")}
focusRequestId={focusRequestId}
/>
);
}
53 changes: 53 additions & 0 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FolderIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";

function getServerHttpOrigin(): string {
const bridgeUrl = window.desktopBridge?.getWsUrl();
const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
const wsUrl =
bridgeUrl && bridgeUrl.length > 0
? bridgeUrl
: envUrl && envUrl.length > 0
? envUrl
: `ws://${window.location.hostname}:${window.location.port}`;
const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
try {
return new URL(httpUrl).origin;
} catch {
return httpUrl;
}
}

const serverHttpOrigin = getServerHttpOrigin();

export function ProjectFavicon(props: {
cwd: string;
className?: string;
fallbackClassName?: string;
}) {
const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(props.cwd)}`;

if (status === "error") {
return (
<FolderIcon
className={cn("size-3.5 shrink-0 text-muted-foreground/50", props.fallbackClassName)}
/>
);
}

return (
<img
src={src}
alt=""
className={cn(
"size-3.5 shrink-0 rounded-sm object-contain",
status === "loading" && "hidden",
props.className,
)}
onLoad={() => setStatus("loaded")}
onError={() => setStatus("error")}
/>
);
}
Loading
Loading