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
24 changes: 22 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,31 @@ jobs:
needs: [preflight, release]
runs-on: ubuntu-24.04
steps:
- id: app_token
name: Mint release app token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}

- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
persist-credentials: true

- id: app_bot
name: Resolve GitHub App bot identity
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
APP_SLUG: ${{ steps.app_token.outputs.app-slug }}
run: |
user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
Expand Down Expand Up @@ -320,8 +340,8 @@ jobs:
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "${{ steps.app_bot.outputs.name }}"
git config user.email "${{ steps.app_bot.outputs.email }}"

git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock
git commit -m "chore(release): prepare $RELEASE_TAG"
Expand Down
15 changes: 0 additions & 15 deletions apps/desktop/src/fixPath.ts

This file was deleted.

4 changes: 2 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts";
import { NetService } from "@t3tools/shared/Net";
import { RotatingFileSink } from "@t3tools/shared/logging";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { fixPath } from "./fixPath";
import { syncShellEnvironment } from "./syncShellEnvironment";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import {
createInitialDesktopUpdateState,
Expand All @@ -44,7 +44,7 @@ import {
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";

fixPath();
syncShellEnvironment();

const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src/syncShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";

import { syncShellEnvironment } from "./syncShellEnvironment";

describe("syncShellEnvironment", () => {
it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

syncShellEnvironment(env, {
platform: "darwin",
readEnvironment,
});

expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
});

it("preserves an inherited SSH_AUTH_SOCK value", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/login-shell.sock",
}));

syncShellEnvironment(env, {
platform: "darwin",
readEnvironment,
});

expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});

it("preserves inherited values when the login shell omits them", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
}));

syncShellEnvironment(env, {
platform: "darwin",
readEnvironment,
});

expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});

it("does nothing outside macOS", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

syncShellEnvironment(env, {
platform: "linux",
readEnvironment,
});

expect(readEnvironment).not.toHaveBeenCalled();
expect(env.PATH).toBe("/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});
});
29 changes: 29 additions & 0 deletions apps/desktop/src/syncShellEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";

export function syncShellEnvironment(
env: NodeJS.ProcessEnv = process.env,
options: {
platform?: NodeJS.Platform;
readEnvironment?: ShellEnvironmentReader;
} = {},
): void {
if ((options.platform ?? process.platform) !== "darwin") return;

try {
const shell = env.SHELL?.trim() || "/bin/zsh";
const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
"PATH",
"SSH_AUTH_SOCK",
]);

if (shellEnvironment.PATH) {
env.PATH = shellEnvironment.PATH;
}

if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
}
} catch {
// Keep inherited environment if shell lookup fails.
}
}
2 changes: 1 addition & 1 deletion apps/web/public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/

const PACKAGE_VERSION = '2.12.9'
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
// Tracks whether the user explicitly dismissed the sidebar for the active turn.
const planSidebarDismissedForTurnRef = useRef<string | null>(null);
// When set, the thread-change reset effect will open the sidebar instead of closing it.
// Used by "Implement in new thread" to carry the sidebar-open intent across navigation.
// Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
const planSidebarOpenOnNextThreadRef = useRef(false);
const [nowTick, setNowTick] = useState(() => Date.now());
const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
Expand Down Expand Up @@ -3977,7 +3977,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
disabled={isSendBusy || isConnecting}
onClick={() => void onImplementPlanInNewThread()}
>
Implement in new thread
Implement in a new thread
</MenuItem>
</MenuPopup>
</Menu>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function resolveThreadRowClassName(input: {
isSelected: boolean;
}): string {
const baseClassName =
"h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring";
"h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring";

if (input.isSelected && input.isActive) {
return cn(
Expand Down
54 changes: 46 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
DEFAULT_MODEL_BY_PROVIDER,
Expand Down Expand Up @@ -993,7 +993,6 @@ export default function Sidebar() {
if (!api) return;
const thread = threads.find((t) => t.id === threadId);
if (!thread) return;

const threadProject = projects.find((project) => project.id === thread.projectId);
// When bulk-deleting, exclude the other threads being deleted so
// getOrphanedWorktreePathForThread correctly detects that no surviving
Expand Down Expand Up @@ -1104,7 +1103,7 @@ export default function Sidebar() {
],
);

const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
onCopy: (ctx) => {
toastManager.add({
type: "success",
Expand All @@ -1120,21 +1119,40 @@ export default function Sidebar() {
});
},
});
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
onCopy: (ctx) => {
toastManager.add({
type: "success",
title: "Path copied",
description: ctx.path,
});
},
onError: (error) => {
toastManager.add({
type: "error",
title: "Failed to copy path",
description: error instanceof Error ? error.message : "An error occurred.",
});
},
});
const handleThreadContextMenu = useCallback(
async (threadId: ThreadId, position: { x: number; y: number }) => {
const api = readNativeApi();
if (!api) return;
const thread = threads.find((t) => t.id === threadId);
if (!thread) return;
const threadWorkspacePath =
thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
const clicked = await api.contextMenu.show(
[
{ id: "rename", label: "Rename thread" },
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-path", label: "Copy Path" },
{ id: "copy-thread-id", label: "Copy Thread ID" },
{ id: "delete", label: "Delete", destructive: true },
],
position,
);
const thread = threads.find((t) => t.id === threadId);
if (!thread) return;

if (clicked === "rename") {
setRenamingThreadId(threadId);
Expand All @@ -1147,8 +1165,20 @@ export default function Sidebar() {
markThreadUnread(threadId);
return;
}
if (clicked === "copy-path") {
if (!threadWorkspacePath) {
toastManager.add({
type: "error",
title: "Path unavailable",
description: "This thread does not have a workspace path to copy.",
});
return;
}
copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
return;
}
if (clicked === "copy-thread-id") {
copyToClipboard(threadId, { threadId });
copyThreadIdToClipboard(threadId, { threadId });
return;
}
if (clicked !== "delete") return;
Expand All @@ -1165,7 +1195,15 @@ export default function Sidebar() {
}
await deleteThread(threadId);
},
[appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads],
[
appSettings.confirmThreadDelete,
copyPathToClipboard,
copyThreadIdToClipboard,
deleteThread,
markThreadUnread,
projectCwdById,
threads,
],
);

const handleMultiSelectContextMenu = useCallback(
Expand Down Expand Up @@ -1783,7 +1821,7 @@ export default function Sidebar() {
<DndContext
sensors={projectDnDSensors}
collisionDetection={projectCollisionDetection}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
onDragStart={handleProjectDragStart}
onDragEnd={handleProjectDragEnd}
onDragCancel={handleProjectDragCancel}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuSubButton,
SidebarProvider,
} from "./sidebar";

function renderSidebarButton(className?: string) {
return renderToStaticMarkup(
<SidebarProvider>
<SidebarMenuButton className={className}>Projects</SidebarMenuButton>
</SidebarProvider>,
);
}

describe("sidebar interactive cursors", () => {
it("uses a pointer cursor for menu buttons by default", () => {
const html = renderSidebarButton();

expect(html).toContain('data-slot="sidebar-menu-button"');
expect(html).toContain("cursor-pointer");
});

it("lets project drag handles override the default pointer cursor", () => {
const html = renderSidebarButton("cursor-grab");

expect(html).toContain("cursor-grab");
expect(html).not.toContain("cursor-pointer");
});

it("uses a pointer cursor for menu actions", () => {
const html = renderToStaticMarkup(
<SidebarMenuAction aria-label="Create thread">
<span>+</span>
</SidebarMenuAction>,
);

expect(html).toContain('data-slot="sidebar-menu-action"');
expect(html).toContain("cursor-pointer");
});

it("uses a pointer cursor for submenu buttons", () => {
const html = renderToStaticMarkup(
<SidebarMenuSubButton render={<button type="button" />}>Show more</SidebarMenuSubButton>,
);

expect(html).toContain('data-slot="sidebar-menu-sub-button"');
expect(html).toContain("cursor-pointer");
});
});
Loading
Loading