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
58 changes: 58 additions & 0 deletions desktop/garyx-desktop/src/main/gary-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,15 @@ interface ThreadsPayload {
sessions?: ThreadSummaryPayload[];
}

interface ThreadPinsPayload {
thread_ids?: string[];
threadIds?: string[];
pins?: Array<{
thread_id?: string;
threadId?: string;
}>;
}

interface ChannelEndpointsPayload {
endpoints?: Array<{
endpoint_key?: string;
Expand Down Expand Up @@ -2889,6 +2898,55 @@ export async function fetchThreads(
return threads.map(mapThreadSummary);
}

function mapThreadPinIds(payload: ThreadPinsPayload): string[] {
const rawIds = Array.isArray(payload.thread_ids)
? payload.thread_ids
: Array.isArray(payload.threadIds)
? payload.threadIds
: Array.isArray(payload.pins)
? payload.pins.map((pin) => pin.thread_id || pin.threadId || "")
: [];
const seen = new Set<string>();
const ids: string[] = [];
for (const rawId of rawIds) {
if (typeof rawId !== "string") {
continue;
}
const id = rawId.trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
ids.push(id);
}
return ids;
}

export async function fetchThreadPins(
settings: DesktopSettings,
): Promise<string[]> {
const payload = await requestJson<ThreadPinsPayload>(settings, "/api/thread-pins", {
signal: AbortSignal.timeout(REMOTE_STATE_FETCH_TIMEOUT_MS),
});
return mapThreadPinIds(payload);
}

export async function setRemoteThreadPinned(
settings: DesktopSettings,
threadId: string,
pinned: boolean,
): Promise<string[]> {
const payload = await requestJson<ThreadPinsPayload>(
settings,
`/api/thread-pins/${encodeURIComponent(threadId)}`,
{
method: pinned ? "PUT" : "DELETE",
signal: AbortSignal.timeout(8000),
},
);
return mapThreadPinIds(payload);
}

export async function createRemoteThread(
settings: DesktopSettings,
input?: {
Expand Down
8 changes: 8 additions & 0 deletions desktop/garyx-desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ import {
updateDesktopAutomation,
removeDesktopWorkspace,
setDesktopBotBinding,
setDesktopThreadPinned,
} from "./store";
import { readMemoryDocument, saveMemoryDocument } from "./memory-documents";
import {
Expand Down Expand Up @@ -1159,6 +1160,13 @@ function registerIpcHandlers(): void {
},
);

ipcMain.handle(
"garyx:set-thread-pinned",
async (_event, input: { threadId: string; pinned: boolean }) => {
return setDesktopThreadPinned(input);
},
);

ipcMain.handle(
"garyx:get-thread-history",
async (_event, input: string | GetThreadHistoryInput) => {
Expand Down
43 changes: 42 additions & 1 deletion desktop/garyx-desktop/src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import {
fetchBotConsoles,
fetchChannelEndpoints,
fetchConfiguredBots,
fetchThreadPins,
fetchThreads,
mapChannelEndpoint,
runRemoteAutomationNow,
setRemoteThreadPinned,
updateRemoteAutomation,
updateRemoteThread,
} from './gary-client';
Expand Down Expand Up @@ -180,6 +182,26 @@ function normalizeNewThreadTitle(value?: string | null): string | undefined {
return trimmed;
}

function normalizePinnedThreadIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const id = entry.trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
ids.push(id);
}
return ids;
}

function normalizeWorkspacePathInput(value?: string | null): string | null {
if (typeof value !== 'string') {
return null;
Expand Down Expand Up @@ -499,6 +521,7 @@ function normalizeState(value?: Partial<DesktopState>): DesktopState {
)
: [],
selectedWorkspacePath: value?.selectedWorkspacePath ?? null,
pinnedThreadIds: normalizePinnedThreadIds(value?.pinnedThreadIds),
threads,
sessions: threads,
endpoints: [],
Expand Down Expand Up @@ -676,9 +699,10 @@ async function fetchRemoteSlice<T>(
}

async function mergeRemoteDesktopState(localState: DesktopState): Promise<DesktopState> {
const [threadsResult, endpointsResult, configuredBotsResult, botConsolesResult, automationsResult] =
const [threadsResult, pinsResult, endpointsResult, configuredBotsResult, botConsolesResult, automationsResult] =
await Promise.all([
fetchRemoteSlice('threads', 'threads', localState.threads, () => fetchThreads(localState.settings)),
fetchRemoteSlice('thread_pins', 'thread pins', localState.pinnedThreadIds, () => fetchThreadPins(localState.settings)),
fetchRemoteSlice('endpoints', 'endpoints', localState.endpoints, () => fetchChannelEndpoints(localState.settings)),
fetchRemoteSlice(
'configured_bots',
Expand All @@ -691,12 +715,14 @@ async function mergeRemoteDesktopState(localState: DesktopState): Promise<Deskto
]);
const remoteErrors = [
threadsResult.error,
pinsResult.error,
endpointsResult.error,
configuredBotsResult.error,
botConsolesResult.error,
automationsResult.error,
].filter((error): error is DesktopRemoteStateError => Boolean(error));
const remoteThreads = threadsResult.value;
const remotePinnedThreadIds = pinsResult.value;
const remoteEndpoints = endpointsResult.value;
const remoteConfiguredBots = configuredBotsResult.value;
const remoteBotConsoles = botConsolesResult.value;
Expand Down Expand Up @@ -779,6 +805,10 @@ async function mergeRemoteDesktopState(localState: DesktopState): Promise<Deskto
...thread,
workspacePath: thread.workspacePath?.trim() || null,
}));
const threadIds = new Set(threads.map((thread) => thread.id));
const pinnedThreadIds = normalizePinnedThreadIds(remotePinnedThreadIds).filter((threadId) => {
return threadIds.has(threadId);
});

const endpoints: DesktopChannelEndpoint[] = remoteEndpoints;
const configuredBots: ConfiguredBot[] = configuredBotsResult.ok
Expand Down Expand Up @@ -808,6 +838,7 @@ async function mergeRemoteDesktopState(localState: DesktopState): Promise<Deskto
...localState,
workspaces,
threads,
pinnedThreadIds,
endpoints,
configuredBots,
botConsoles,
Expand Down Expand Up @@ -1110,6 +1141,16 @@ export async function removeDesktopWorkspace(workspacePath: string): Promise<Des
return getDesktopState();
}

export async function setDesktopThreadPinned(input: {
threadId: string;
pinned: boolean;
}): Promise<DesktopState> {
const current = await getDesktopState();
const thread = requireThread(current, input.threadId);
await setRemoteThreadPinned(current.settings, thread.id, input.pinned);
return getDesktopState();
}

export async function createDesktopThread(input?: {
title?: string;
workspacePath?: string | null;
Expand Down
2 changes: 2 additions & 0 deletions desktop/garyx-desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ const api: GaryxDesktopApi = {
ipcRenderer.invoke("garyx:get-workspace-git-status", input),
renameThread: (input) => ipcRenderer.invoke("garyx:rename-thread", input),
deleteThread: (input) => ipcRenderer.invoke("garyx:delete-thread", input),
setThreadPinned: (input) =>
ipcRenderer.invoke("garyx:set-thread-pinned", input),
getThreadHistory: (input) =>
ipcRenderer.invoke("garyx:get-thread-history", input),
getThreadLogs: (threadId, cursor) =>
Expand Down
116 changes: 51 additions & 65 deletions desktop/garyx-desktop/src/renderer/src/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,50 +483,6 @@ function formatThreadTimestamp(value?: string | null): string {
return `${Math.floor(diffDay / 365)}y`;
}

const PINNED_THREAD_IDS_STORAGE_KEY = "garyx.desktop.pinnedThreadIds";

function normalizePinnedThreadIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}

const seen = new Set<string>();
const ids: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const id = entry.trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
ids.push(id);
}
return ids;
}

function readPinnedThreadIds(): string[] {
try {
return normalizePinnedThreadIds(
JSON.parse(window.localStorage.getItem(PINNED_THREAD_IDS_STORAGE_KEY) || "[]"),
);
} catch {
return [];
}
}

function writePinnedThreadIds(ids: string[]) {
try {
window.localStorage.setItem(
PINNED_THREAD_IDS_STORAGE_KEY,
JSON.stringify(normalizePinnedThreadIds(ids)),
);
} catch {
// Local storage may be unavailable in constrained renderer contexts.
}
}

function botLabel(channel: string, accountId: string): string {
return accountId?.trim() || channelDisplayName(channel);
}
Expand Down Expand Up @@ -1683,9 +1639,6 @@ export function AppShell() {
const [botConversationGroupId, setBotConversationGroupId] = useState<string | null>(null);
const [workspaceConversationPath, setWorkspaceConversationPath] =
useState<string | null>(null);
const [pinnedThreadIds, setPinnedThreadIds] = useState<string[]>(() =>
readPinnedThreadIds(),
);
const sidebarResizeStateRef = useRef<{
startX: number;
startWidth: number;
Expand Down Expand Up @@ -1713,9 +1666,6 @@ export function AppShell() {
const [contentView, setContentViewRaw] = useState<ContentView>(() =>
initialContentView(initialRouteValue),
);
useEffect(() => {
writePinnedThreadIds(pinnedThreadIds);
}, [pinnedThreadIds]);
useEffect(() => {
if (contentView !== "thread" || !selectedThreadId) {
setThreadEntrySelectionSource(null);
Expand Down Expand Up @@ -2356,22 +2306,14 @@ export function AppShell() {
}
return summaries;
}, [activeThread, desktopState]);
const pinnedThreadIds = desktopState?.pinnedThreadIds || [];
const pinnedThreadIdSet = useMemo(
() => new Set(pinnedThreadIds),
[pinnedThreadIds],
);
const selectedThreadPinned = selectedThreadId
? pinnedThreadIdSet.has(selectedThreadId)
: false;
useEffect(() => {
if (!desktopState) {
return;
}
setPinnedThreadIds((current) => {
const next = current.filter((threadId) => threadSummaryById.has(threadId));
return next.length === current.length ? current : next;
});
}, [desktopState, threadSummaryById]);
const activeThreadTeamView = deriveThreadTeamView(activeThread);
const desktopAgentMap = new Map(
desktopAgents.map((agent) => [agent.agentId, agent] as const),
Expand Down Expand Up @@ -2905,13 +2847,57 @@ export function AppShell() {
visibleThreadEntrySelectionSource,
],
);
function pinnedThreadIdsWith(
ids: string[],
threadId: string,
pinned: boolean,
): string[] {
const normalizedId = threadId.trim();
if (!normalizedId) {
return ids;
}
const withoutThread = ids.filter((id) => id !== normalizedId);
return pinned ? [normalizedId, ...withoutThread] : withoutThread;
}

async function setThreadPinned(threadId: string, pinned: boolean) {
const normalizedId = threadId.trim();
if (!normalizedId) {
return;
}
setDesktopState((current) =>
current
? {
...current,
pinnedThreadIds: pinnedThreadIdsWith(
current.pinnedThreadIds || [],
normalizedId,
pinned,
),
}
: current,
);
try {
const nextState = await window.garyxDesktop.setThreadPinned({
threadId: normalizedId,
pinned,
});
setDesktopState(nextState);
} catch (error) {
setError(
error instanceof Error
? error.message
: pinned
? t("Failed to pin thread")
: t("Failed to unpin thread"),
);
void refreshDesktopState().catch(() => null);
}
}

function togglePinnedThread(threadId: string) {
setPinnedThreadIds((current) => {
if (current.includes(threadId)) {
return current.filter((id) => id !== threadId);
}
return [threadId, ...current];
});
const pinned = (desktopState?.pinnedThreadIds || []).includes(threadId);
void setThreadPinned(threadId, !pinned);
}
useEffect(() => {
if (shouldShowConversationRail) {
Expand Down
2 changes: 2 additions & 0 deletions desktop/garyx-desktop/src/renderer/src/i18n/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ const zhCN: Record<string, string> = {
'Open {name} thread': '打开 {name} 线程',
'Opening folder': '正在打开文件夹',
'Opening folder…': '正在打开文件夹…',
'Failed to pin thread': '置顶线程失败',
'Failed to unpin thread': '取消置顶线程失败',
'Pin thread': '置顶线程',
'Pinned': '置顶',
'Paste a Claude, Codex, or Gemini session ID. Garyx will recover its workspace and bind a thread to it.': '粘贴 Claude、Codex 或 Gemini 的 session ID。Garyx 会恢复它的工作区并绑定一个线程。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function makeState(overrides = {}) {
workspaces: [],
hiddenWorkspacePaths: [],
selectedWorkspacePath: null,
pinnedThreadIds: [],
threads: [],
sessions: [],
endpoints: [],
Expand Down
Loading
Loading