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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const clientSettings: ClientSettings = {
boringSidebarEnabled: false,
boringSidebarMultilineRows: false,
threadDetailsUsageEnabled: false,
threadDetailsItems: {},
legacySidebarEnabled: false,
sidebarWorkingShelfEnabled: false,
loadBalancingEnabled: false,
Expand Down
19 changes: 6 additions & 13 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1228,9 +1228,6 @@ export default function ChatView(props: ChatViewProps) {
const canChangeEnvironment =
hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo;

const threadDetailsUsageEnabled = useClientSettings(
(settings) => settings.threadDetailsUsageEnabled,
);
const loadBalancingSettings = useClientSettings((settings) => ({
loadBalancingEnabled: settings.loadBalancingEnabled,
loadBalancingWeights: settings.loadBalancingWeights,
Expand Down Expand Up @@ -3773,16 +3770,12 @@ export default function ChatView(props: ChatViewProps) {
onAddProjectScript={saveProjectScriptStable}
onUpdateProjectScript={updateProjectScriptStable}
onDeleteProjectScript={deleteProjectScriptStable}
usage={
threadDetailsUsageEnabled
? {
providerInstanceId: activeProviderInstanceId,
providers: providerStatuses,
sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES,
contextWindow: activeContextWindow,
}
: undefined
}
usage={{
providerInstanceId: activeProviderInstanceId,
providers: providerStatuses,
sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES,
contextWindow: activeContextWindow,
}}
/>

<PullRequestThreadDialogHost
Expand Down
25 changes: 16 additions & 9 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ interface GitActionsControlProps {
activeThreadRef: ScopedThreadRef | null;
draftId?: DraftId;
compact?: boolean;
showActions?: boolean;
showChanges?: boolean;
onOpenChanges?: () => void;
}

Expand Down Expand Up @@ -926,6 +928,8 @@ export default function GitActionsControl({
activeThreadRef,
draftId,
compact = false,
showActions = true,
showChanges = true,
onOpenChanges,
}: GitActionsControlProps) {
const panelAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1476,7 +1480,7 @@ export default function GitActionsControl({

return (
<>
{!isRepo ? (
{!showActions ? null : !isRepo ? (
<ThreadDetailsControl
size="xs"
variant="ghost"
Expand Down Expand Up @@ -1618,7 +1622,7 @@ export default function GitActionsControl({
</div>
)}

{isRepo && !quietEmptyDraft ? (
{showChanges && isRepo && !quietEmptyDraft ? (
<ThreadDetailsControl
type="button"
variant="ghost"
Expand Down Expand Up @@ -1826,13 +1830,16 @@ export default function GitActionsControl({
</DialogPopup>
</Dialog>

<PublishRepositoryDialog
open={isPublishDialogOpen}
onOpenChange={setIsPublishDialogOpen}
environmentId={activeEnvironmentId}
threadRef={activeThreadRef}
gitCwd={gitCwd}
/>
{/* Its discovery query should stop when the Git actions row that opens it is hidden. */}
{showActions ? (
<PublishRepositoryDialog
open={isPublishDialogOpen}
onOpenChange={setIsPublishDialogOpen}
environmentId={activeEnvironmentId}
threadRef={activeThreadRef}
gitCwd={gitCwd}
/>
) : null}

<Dialog
open={pendingDefaultBranchAction !== null}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ describe("host editor access", () => {
});
});

describe("remote editor links", () => {
it("opens the link from the fallback when the picker is not mounted", async () => {
await using fixture = await setup();
const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture;
state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } };
await act(async () => screen.rerender(<FavoriteEditorFallback />));

expect((await pressOpenFavorite()).defaultPrevented).toBe(true);
expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol"));
expect(state.run).not.toHaveBeenCalled();
});
});

describe("open in editor announcement", () => {
it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => {
await using fixture = await setup();
Expand Down
40 changes: 33 additions & 7 deletions apps/web/src/components/chat/OpenInPickerShortcut.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tools/contracts";
import {
buildRemoteOpenUrl,
type EditorId,
type EnvironmentId,
type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
import { useEffect } from "react";

import { usePreferredEditor } from "../../editorPreferences";
import { editorLabelForPlatform } from "../../editorLabels";
import { isOpenFavoriteEditorShortcut } from "../../keybindings";
import { announce } from "../../lib/announce";
import { useRemoteOpenState } from "../../remoteOpen";
import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen";
import { registerCommands } from "../../shortcuts/dispatcher";
import { shellEnvironment } from "../../state/shell";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -49,18 +54,39 @@ export function useOpenFavoriteEditorShortcut({
openInCwd: string | null;
}) {
const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");
const [preferredEditor] = usePreferredEditor(availableEditors);
const remote = useRemoteOpenState(environmentId);
const remoteCapableEditors = useRemoteCapableEditors();
// Matches the picker: remote editors run on the viewing machine.
const [preferredEditor] = usePreferredEditor(
remote.mode === "local-exec" ? availableEditors : remoteCapableEditors,
);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled locally
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled
useEffect(() => {
if (!enabled || remote.mode !== "local-exec") return;
if (!enabled || remote.mode === "remote-unavailable") return;
const handler = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented || !isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (
event.repeat ||
event.defaultPrevented ||
!isOpenFavoriteEditorShortcut(event, keybindings)
)
return;
if (!openInCwd || !preferredEditor) return;

event.preventDefault();
const label = editorLabelForPlatform(preferredEditor, navigator.platform);
if (remote.mode === "remote-links") {
const url = buildRemoteOpenUrl({
editor: preferredEditor,
...remote.host,
absolutePath: openInCwd,
});
if (url === undefined) return;
void openRemoteEditorUrl(url).then((opened) => {
if (opened) announce(`Opened in ${label}`);
});
return;
}
void openInEditorMutation({
environmentId,
input: {
Expand All @@ -80,6 +106,6 @@ export function useOpenFavoriteEditorShortcut({
openInCwd,
openInEditorMutation,
preferredEditor,
remote.mode,
remote,
]);
}
12 changes: 6 additions & 6 deletions apps/web/src/components/chat/ThreadAutomationsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,6 @@ export function ThreadAutomationsPanel(props: {
</Tooltip>
}
>
{tasksQuery.error !== null ? (
<p className="px-2.5 py-1.5 text-2xs text-destructive">
Could not load automations: {tasksQuery.error}
</p>
) : null}

<ul className="m-0 list-none p-0">
{boundTasks.map((task) => (
<li
Expand Down Expand Up @@ -242,6 +236,12 @@ export function ThreadAutomationsPanel(props: {
</li>
))}
</ul>
{/* Kept last: the details card treats a section whose last child is empty as empty. */}
{tasksQuery.error !== null ? (
<p className="px-2.5 py-1.5 text-2xs text-destructive">
Could not load automations: {tasksQuery.error}
</p>
) : null}
</ThreadDetailsSection>
);
}
26 changes: 24 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ function Card({
threadRef,
inline,
fixture,
rows = 1,
}: {
threadRef: ScopedThreadRef;
inline: boolean;
fixture: ReturnType<typeof setup>;
/** Rows of 100px shown at full density. Compact always takes 50px. */
rows?: number;
}) {
return (
// The web root renders in StrictMode, which re-runs ref callbacks on every mount.
Expand All @@ -63,8 +66,17 @@ function Card({
detailsCardPlacement: inline ? placement : null,
}}
>
<ThreadDetailsCard threadRef={threadRef} anchor={fixture.anchor} handle={fixture.handle}>
{() => <p>Details for {threadRef.threadId}</p>}
<ThreadDetailsCard
threadRef={threadRef}
anchor={fixture.anchor}
handle={fixture.handle}
contentKey={String(rows)}
>
{(density) => (
<p style={{ height: density === "full" ? rows * 100 : 50 }}>
Details for {threadRef.threadId}
</p>
)}
</ThreadDetailsCard>
</ChatCanvasContext>
</StrictMode>
Expand Down Expand Up @@ -136,3 +148,13 @@ it("reopens after a normal close", async () => {
await setOpen(true);
await expect.element(page.getByText("Details for thread-b")).toBeVisible();
});

it("returns to full density when hidden items make the content fit", async () => {
using fixture = setup();
await using screen = await render(<Card threadRef={threadB} inline fixture={fixture} rows={6} />);
const card = page.getByRole("region", { name: "Thread details" });
await expect.element(card).toHaveAttribute("data-density", "compact");

await screen.rerender(<Card threadRef={threadB} inline fixture={fixture} rows={2} />);
await expect.element(card).toHaveAttribute("data-density", "full");
});
16 changes: 14 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { workspaceAreaProps } from "../../lib/workspaceFocus";
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import {
useLayoutEffect,
useState,
type MouseEventHandler,
type ReactNode,
type RefObject,
} from "react";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { ScrollArea } from "../ui/scroll-area";
import { cn } from "../../lib/utils";
Expand All @@ -17,11 +23,16 @@ export function ThreadDetailsCard({
threadRef,
anchor,
handle,
contentKey,
onContextMenu,
children,
}: {
threadRef: ScopedThreadRef;
anchor: RefObject<Element | null>;
handle: ReturnType<typeof PopoverCreateHandle>;
/** Changes when the set of rendered items changes, so cached heights are measured again. */
contentKey: string;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
children: (density: "full" | "compact" | "essential") => ReactNode;
}) {
const detailsArea = workspaceAreaProps("details", 3, "Thread details");
Expand All @@ -36,7 +47,7 @@ export function ThreadDetailsCard({
const popoverOpen = useRightPanelStore((state) =>
selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"),
);
const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`;
const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}:${contentKey}`;
const [measurements, setMeasurements] = useState({
key: measurementKey,
heights: { full: 0, compact: 0 },
Expand Down Expand Up @@ -109,6 +120,7 @@ export function ThreadDetailsCard({
"max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]",
)}
style={placement ? { maxHeight: height } : undefined}
onContextMenu={onContextMenu}
data-thread-details-card
>
<ScrollArea scrollFade className="min-h-0">
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
getClientSettings,
updateClientSettings,
useClientSettings,
} from "../../hooks/useSettings";
import { MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator } from "../ui/menu";
import {
isDefaultThreadDetailsVisibility,
resolveThreadDetailsVisibility,
RESET_THREAD_DETAILS_ITEMS,
setThreadDetailsItem,
THREAD_DETAILS_ITEMS,
} from "./threadDetailsItems";

/** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */
export function ThreadDetailsItemsMenuItems() {
const visibility = useClientSettings(resolveThreadDetailsVisibility);
return (
<>
<MenuGroup>
<MenuGroupLabel>Show in thread details</MenuGroupLabel>
{THREAD_DETAILS_ITEMS.map((item) => (
<MenuCheckboxItem
key={item.id}
checked={visibility[item.id]}
onCheckedChange={(checked) =>
updateClientSettings({
threadDetailsItems: setThreadDetailsItem(getClientSettings(), item, checked),
})
}
>
{item.label}
</MenuCheckboxItem>
))}
</MenuGroup>
<MenuSeparator />
<MenuItem
disabled={isDefaultThreadDetailsVisibility(visibility)}
onClick={() => updateClientSettings(RESET_THREAD_DETAILS_ITEMS)}
>
Reset to default
</MenuItem>
</>
);
}
Loading
Loading