diff --git a/.pr/conversation-panel-tags/conversation-list-tags.png b/.pr/conversation-panel-tags/conversation-list-tags.png new file mode 100644 index 0000000000..bd24db186e Binary files /dev/null and b/.pr/conversation-panel-tags/conversation-list-tags.png differ diff --git a/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx b/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx new file mode 100644 index 0000000000..b575b426d5 --- /dev/null +++ b/__tests__/components/features/conversation-panel/conversation-card-preview.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "test-utils"; +import { ConversationCardPreview } from "#/components/features/conversation-panel/conversation-card/conversation-card-preview"; + +const PREVIEW_TITLE = "Conversation 1"; + +describe("ConversationCardPreview", () => { + it("renders free-form tags and skips reserved repo / branch / workspace keys", () => { + renderWithProviders( + , + ); + + expect(screen.queryByTestId("conversation-card-tag-chip")).not.toBeInTheDocument(); + + const rows = screen.getAllByTestId("conversation-card-preview-tag-row"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute("data-tag-key", "origin"); + expect(rows[0]).toHaveTextContent("slack"); + expect(rows[1]).toHaveAttribute("data-tag-key", "owner"); + expect(rows[1]).toHaveTextContent("alice"); + }); + + it("still shows first-class repo / branch rows from selectedRepository", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("org/repo")).toBeInTheDocument(); + expect(screen.getByText("main")).toBeInTheDocument(); + const rows = screen.getAllByTestId("conversation-card-preview-tag-row"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveAttribute("data-tag-key", "owner"); + }); + + it("shows Directory from workspaceWorkingDir instead of a workspace tag", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("/workspace/project")).toBeInTheDocument(); + const rows = screen.getAllByTestId("conversation-card-preview-tag-row"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveAttribute("data-tag-key", "owner"); + }); + + it("shows full tag values without truncation", () => { + const longValue = "a-very-long-custom-tag-value-that-exceeds-chip-budget"; + renderWithProviders( + , + ); + + expect(screen.getByTestId("conversation-card-preview-tag-row")).toHaveTextContent( + longValue, + ); + }); + + it("styles the model row with the OpenHands brand icon", () => { + renderWithProviders( + , + ); + + expect( + screen.getByTestId("conversation-card-preview-model"), + ).toHaveTextContent("openhands/claude-opus-4-5-20251101"); + expect( + screen.getByTestId("agent-brand-icon-openhands"), + ).toBeInTheDocument(); + }); + + it("uses the ACP provider mark on the model row for ACP conversations", () => { + // The card chip resolves the ACP brand mark; the hovercard for the same + // conversation must not contradict it with the OpenHands wordmark. + renderWithProviders( + , + ); + + expect( + screen.getByTestId("agent-brand-icon-claude-code"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-brand-icon-openhands"), + ).not.toBeInTheDocument(); + }); + + it("omits blank tag values from the hovercard", () => { + renderWithProviders( + , + ); + + const rows = screen.getAllByTestId("conversation-card-preview-tag-row"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute("data-tag-key", "appmode"); + expect(rows[0]).toHaveTextContent("work"); + expect(rows[1]).toHaveAttribute("data-tag-key", "workwsid"); + expect(rows[1]).toHaveTextContent("abc"); + }); +}); diff --git a/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/__tests__/components/features/conversation-panel/conversation-card.test.tsx index 4ab9c2d99f..0a367d424e 100644 --- a/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ b/__tests__/components/features/conversation-panel/conversation-card.test.tsx @@ -13,7 +13,6 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { formatTimeDelta } from "#/utils/format-time-delta"; import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card"; -import { MAX_VISIBLE_TAG_CHIPS } from "#/components/features/conversation-panel/conversation-card/conversation-card-footer"; import { clickOnEditButton } from "./utils"; import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions"; import { ExecutionStatus } from "#/types/agent-server/core/base/common"; @@ -134,6 +133,75 @@ describe("ConversationCard", () => { screen.getByTestId("conversation-card-selected-repository"); }); + it("styles repo and branch with the same raised pill chip as tags", () => { + renderWithProviders( + , + ); + + const repo = screen.getByTestId("conversation-card-selected-repository"); + const branch = screen.getByTestId("conversation-card-selected-branch"); + const tag = screen.getByTestId("conversation-card-tag-chip"); + + expect(repo).toHaveClass("bg-[var(--oh-surface-raised)]"); + expect(branch).toHaveClass("bg-[var(--oh-surface-raised)]"); + expect(tag).toHaveClass("bg-[var(--oh-surface-raised)]"); + + // Identical pill look. The one intentional difference is flex-shrink: + // repo and branch share a single overflow-hidden row, so they must shrink + // (otherwise a long repo name evicts the branch chip entirely), while a + // tag chip keeps its intrinsic width and folds behind "+N" instead. + const pillLook = (element: HTMLElement) => + element.className + .split(/\s+/) + .filter((name) => name !== "shrink" && name !== "shrink-0") + .sort() + .join(" "); + + expect(pillLook(repo)).toBe(pillLook(tag)); + expect(pillLook(branch)).toBe(pillLook(tag)); + expect(repo).toHaveClass("shrink"); + expect(repo).not.toHaveClass("shrink-0"); + expect(branch).toHaveClass("shrink"); + expect(branch).not.toHaveClass("shrink-0"); + expect(tag).toHaveClass("shrink-0"); + }); + + it("stacks metadata as repo/branch, then model, then tags", () => { + renderWithProviders( + , + ); + + const repo = screen.getByTestId("conversation-card-selected-repository"); + const model = screen.getByTestId("conversation-card-agent-chip"); + const tags = screen.getByTestId("conversation-card-tag-chips"); + + expect(repo.compareDocumentPosition(model) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(model.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it("renders the workspace folder name when no repository is selected", () => { renderWithProviders( { ).toBeInTheDocument(); }); + it("styles the no-repository label with the same raised pill chip", () => { + renderWithProviders( + , + ); + + const noRepo = screen.getByTestId("conversation-card-no-repository"); + const tag = screen.getByTestId("conversation-card-tag-chip"); + expect(noRepo).toHaveTextContent("No repository"); + expect(noRepo.className).toBe(tag.className); + }); + it("handles Windows workspace paths and falls back when the path is empty", () => { const { rerender } = renderWithProviders( { describe("Tag chips", () => { // Tag chips surface the agent-server's server-side conversation tags // (e.g. ``origin=slack`` stamped by an automation) and are gated by the - // conversation panel's "Tags" toggle (``showTags``). - it("renders non-reserved tags as key: value chips when showTags is on", () => { + // conversation panel's "Tags" toggle (``showTags``). Chip labels are + // value-only; the full ``key: value`` lives in the chip tooltip. + it("renders non-reserved tags as value-only chips when showTags is on", () => { renderWithProviders( { ); const chips = screen.getAllByTestId("conversation-card-tag-chip"); - // Chips are sorted by key ("origin" < "owner") for a stable order. + // ``origin`` is a priority key, so it leads; remaining keys sort A–Z. expect(chips).toHaveLength(2); - expect(chips[0]).toHaveTextContent("origin: slack"); - expect(chips[1]).toHaveTextContent("owner: alice"); + expect(chips[0]).toHaveTextContent("slack"); + expect(chips[0].getAttribute("title")).toMatch(/: slack$/); + expect(chips[0].getAttribute("title")).not.toContain("origin"); + expect(chips[1]).toHaveTextContent("alice"); + expect(chips[1]).toHaveAttribute("title", "Owner: alice"); + expect( + within(chips[0]).getByTestId("conversation-card-tag-chip-icon"), + ).toHaveAttribute("data-tag-key", "origin"); + expect( + within(chips[1]).getByTestId("conversation-card-tag-chip-icon"), + ).toHaveAttribute("data-tag-key", "owner"); }); it("filters reserved tag keys out of the chip row", () => { - // ``acpserver`` is Canvas-internal routing state already surfaced via - // the agent chip — it must not double-render as a generic tag chip. + // Reserved keys already have a first-class UI source (ACP chip, title, + // repo/branch/workspace metadata) and must not double-render as tags. renderWithProviders( , ); const chips = screen.getAllByTestId("conversation-card-tag-chip"); expect(chips).toHaveLength(1); - expect(chips[0]).toHaveTextContent("origin: review"); + expect(chips[0]).toHaveTextContent("review"); + expect(chips[0].getAttribute("title")).toMatch(/: review$/); + expect(chips[0].getAttribute("title")).not.toContain("origin"); }); it("keeps the automation name/trigger chips but hides the automation id chips", () => { // The automation id/run-id tags are raw UUIDs consumed by the panel's // automation filter — chip noise — while the human-meaningful name and - // trigger stay visible. + // trigger stay visible. Like every tag chip they render value-only, + // with the humanized ``key: value`` pair in the tooltip. renderWithProviders( { const chips = screen.getAllByTestId("conversation-card-tag-chip"); expect(chips).toHaveLength(2); - expect(chips[0]).toHaveTextContent("automationname: Nightly Audit"); - expect(chips[1]).toHaveTextContent("automationtrigger: cron"); + expect(chips[0]).toHaveTextContent("Nightly Audit"); + expect(chips[0]).toHaveAttribute( + "title", + "Automationname: Nightly Audit", + ); + expect(chips[1]).toHaveTextContent("cron"); + expect(chips[1]).toHaveAttribute("title", "Automationtrigger: cron"); }); it("hides the chips when showTags is omitted", () => { @@ -669,39 +780,7 @@ describe("ConversationCard", () => { ).not.toBeInTheDocument(); }); - it("caps visible chips at the display budget with a +N overflow chip", () => { - // Tags are API-controlled with no useful size bound, so the card must - // not grow with the tag count: first MAX_VISIBLE_TAG_CHIPS sorted keys - // render as chips, the rest fold into "+N" with a tooltip listing them. - renderWithProviders( - , - ); - - const chips = screen.getAllByTestId("conversation-card-tag-chip"); - expect(chips).toHaveLength(MAX_VISIBLE_TAG_CHIPS); - expect(chips[0]).toHaveTextContent("env: prod"); - expect(chips[1]).toHaveTextContent("origin: slack"); - expect(chips[2]).toHaveTextContent("owner: alice"); - - const overflow = screen.getByTestId("conversation-card-tag-overflow"); - expect(overflow).toHaveTextContent("+2"); - // The hidden remainder stays reachable via the overflow tooltip. - expect(overflow).toHaveAttribute("title", "repo: goodday\nteam: infra"); - }); - - it("renders no overflow chip when tags fit the display budget", () => { + it("keeps chips on a single nowrap row", () => { renderWithProviders( { />, ); - expect( - screen.getAllByTestId("conversation-card-tag-chip"), - ).toHaveLength(3); - expect( - screen.queryByTestId("conversation-card-tag-overflow"), - ).not.toBeInTheDocument(); + const row = screen.getByTestId("conversation-card-tag-row"); + expect(row).toHaveClass("flex-nowrap"); + expect(row).toHaveClass("overflow-hidden"); + }); + + it("hard-truncates long chip values while keeping the full tooltip", () => { + const longValue = "abcdefghijklmnopqrstuvwxyz"; + renderWithProviders( + , + ); + + const chip = screen.getByTestId("conversation-card-tag-chip"); + expect(chip).toHaveTextContent("abcdefghijklm…"); + expect(chip).toHaveAttribute("title", `Token: ${longValue}`); }); it("renders no chip row when every tag is reserved", () => { diff --git a/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx index 5476db5fa7..820585a718 100644 --- a/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx +++ b/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx @@ -66,6 +66,21 @@ describe("ConversationPanelFilterMenu", () => { expect(screen.getByTestId("delete-all-conversations")).toBeInTheDocument(); }); + it("orders metadata toggles as repo/branch, model, then tags", () => { + renderFilterMenu({ filterMenuOpen: true }); + + const repo = screen.getByTestId("toggle-repo-branch-metadata"); + const model = screen.getByTestId("toggle-llm-profiles"); + const tags = screen.getByTestId("toggle-tags-metadata"); + + expect( + repo.compareDocumentPosition(model) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + model.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("runs a row's action and closes the menu when the row is clicked", async () => { // Arrange const user = userEvent.setup(); diff --git a/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx b/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx new file mode 100644 index 0000000000..103c99c07b --- /dev/null +++ b/__tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { ConversationTagChips } from "#/components/features/conversation-panel/conversation-card/conversation-tag-chips"; + +describe("ConversationTagChips", () => { + const observedCallbacks: ResizeObserverCallback[] = []; + + beforeEach(() => { + observedCallbacks.length = 0; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(cb: ResizeObserverCallback) { + observedCallbacks.push(cb); + } + + observe() { + const cb = observedCallbacks[observedCallbacks.length - 1]; + cb?.([], this as unknown as ResizeObserver); + } + + disconnect() {} + + unobserve() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubWidths(containerWidth: number, chipWidth: number) { + const row = screen.getByTestId("conversation-card-tag-row"); + Object.defineProperty(row, "clientWidth", { + configurable: true, + get: () => containerWidth, + }); + + const measure = row + .closest('[data-testid="conversation-card-tag-chips"]') + ?.querySelector('[aria-hidden="true"]') as HTMLElement; + Array.from(measure.children).forEach((child) => { + Object.defineProperty(child, "offsetWidth", { + configurable: true, + get: () => chipWidth, + }); + }); + + act(() => { + for (const cb of observedCallbacks) { + cb([], {} as ResizeObserver); + } + }); + } + + it("folds chips that do not fit into a +N popover with friendly labels", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + // Wide enough for one 40px chip + overflow reserve, not two. + stubWidths(80, 40); + + await waitFor(() => { + expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength( + 1, + ); + }); + const visibleChip = screen.getByTestId("conversation-card-tag-chip"); + expect(visibleChip).toHaveTextContent("slack"); + // Tooltip uses the humanized label — "Origin", not the wire key and not + // "Git" (origin names the source of the conversation, not a git fact). + expect(visibleChip).toHaveAttribute("title", "Origin: slack"); + + const overflow = screen.getByTestId("conversation-card-tag-overflow"); + expect(overflow).toHaveTextContent("+2"); + + await user.click(overflow); + + const popover = screen.getByTestId( + "conversation-card-tag-overflow-popover", + ); + expect(popover.parentElement).toBe(document.body); + const rows = within(popover).getAllByTestId( + "conversation-card-tag-overflow-row", + ); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent("prod"); + expect(rows[0]).toHaveTextContent("Env"); + expect(rows[1]).toHaveTextContent("Owner"); + expect(rows[1]).toHaveTextContent("alice"); + }); + + it("opens the overflow popover without activating a wrapping link", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn((event: React.MouseEvent) => { + event.preventDefault(); + }); + + renderWithProviders( + // Conversation cards sit inside NavigationLink anchors; the +N control + // must swallow the activation so the popover can open in place. + + + + , + ); + + stubWidths(80, 40); + + await waitFor(() => { + expect( + screen.getByTestId("conversation-card-tag-overflow"), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("conversation-card-tag-overflow")); + + expect( + screen.getByTestId("conversation-card-tag-overflow-popover"), + ).toBeInTheDocument(); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("hides the overflow control when every chip fits", async () => { + renderWithProviders( + , + ); + + stubWidths(200, 40); + + await waitFor(() => { + expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength( + 2, + ); + }); + expect( + screen.queryByTestId("conversation-card-tag-overflow"), + ).not.toBeInTheDocument(); + }); + + it("emits chip icon test ids only for visible chips, not the measure row", async () => { + // The off-screen measure row renders the same chips; tagging those too + // would make `getByTestId` (singular) ambiguous for every tagged card. + renderWithProviders( + , + ); + + stubWidths(200, 40); + + await waitFor(() => { + expect(screen.getAllByTestId("conversation-card-tag-chip")).toHaveLength( + 2, + ); + }); + expect( + screen.getAllByTestId("conversation-card-tag-chip-icon"), + ).toHaveLength(2); + }); + + it("keeps the overflow popover open across re-renders with unchanged tags", async () => { + // Callers rebuild the tag array on every render, and the conversation + // panel re-renders at least every 10s (`refetchInterval`). Resetting on + // array identity would slam the popover shut under the user's cursor. + const user = userEvent.setup(); + const { rerender } = renderWithProviders( + , + ); + + stubWidths(80, 40); + + await waitFor(() => { + expect( + screen.getByTestId("conversation-card-tag-overflow"), + ).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("conversation-card-tag-overflow")); + expect( + screen.getByTestId("conversation-card-tag-overflow-popover"), + ).toBeInTheDocument(); + + // Same content, fresh array identity — exactly what the parent produces. + rerender( + , + ); + + expect( + screen.getByTestId("conversation-card-tag-overflow-popover"), + ).toBeInTheDocument(); + }); + + it("closes the overflow popover when the tags themselves change", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithProviders( + , + ); + + stubWidths(80, 40); + + await waitFor(() => { + expect( + screen.getByTestId("conversation-card-tag-overflow"), + ).toBeInTheDocument(); + }); + await user.click(screen.getByTestId("conversation-card-tag-overflow")); + expect( + screen.getByTestId("conversation-card-tag-overflow-popover"), + ).toBeInTheDocument(); + + rerender( + , + ); + + expect( + screen.queryByTestId("conversation-card-tag-overflow-popover"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts b/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts new file mode 100644 index 0000000000..e21fd73e94 --- /dev/null +++ b/__tests__/components/features/conversation-panel/conversation-tag-display.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { + computeVisibleTagChipCount, + formatConversationTagTooltip, + getConversationTagLabel, + getConversationTagLabelKind, + humanizeConversationTagKey, + TAG_CHIP_GAP_PX, + TAG_CHIP_OVERFLOW_WIDTH_PX, + TAG_CHIP_VALUE_MAX_LENGTH, + truncateTagChipValue, +} from "#/components/features/conversation-panel/conversation-card/conversation-tag-display"; +import { getDisplayConversationTags } from "#/api/agent-server-adapter"; +import { I18nKey } from "#/i18n/declaration"; + +describe("truncateTagChipValue", () => { + it("leaves short values unchanged", () => { + expect(truncateTagChipValue("slack")).toBe("slack"); + expect(truncateTagChipValue("a".repeat(TAG_CHIP_VALUE_MAX_LENGTH))).toBe( + "a".repeat(TAG_CHIP_VALUE_MAX_LENGTH), + ); + }); + + it("hard-truncates long values with an ellipsis within the budget", () => { + const value = "a".repeat(TAG_CHIP_VALUE_MAX_LENGTH + 8); + const truncated = truncateTagChipValue(value); + expect(truncated).toHaveLength(TAG_CHIP_VALUE_MAX_LENGTH); + expect(truncated.endsWith("…")).toBe(true); + expect( + truncated.startsWith("a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)), + ).toBe(true); + }); + + it("never splits a surrogate pair at the cut point", () => { + // Slack-/Discord-stamped values carry emoji; slicing by UTF-16 code unit + // would leave a lone surrogate that renders as a replacement glyph. + const value = `${"a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}🎉extra`; + const truncated = truncateTagChipValue(value); + + expect(truncated).toBe(`${"a".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}…`); + for (const codePoint of truncated) { + expect(codePoint.codePointAt(0)).toBeLessThan(0xd800); + } + }); + + it("counts astral characters as one character each", () => { + // 13 emoji + one more: 14 code points fits the budget untouched. + const value = "🎉".repeat(TAG_CHIP_VALUE_MAX_LENGTH); + expect(truncateTagChipValue(value)).toBe(value); + expect(truncateTagChipValue(`${value}🎉`)).toBe( + `${"🎉".repeat(TAG_CHIP_VALUE_MAX_LENGTH - 1)}…`, + ); + }); +}); + +describe("computeVisibleTagChipCount", () => { + it("shows every chip when the container has not been measured yet", () => { + expect(computeVisibleTagChipCount([40, 40, 40], 0)).toBe(3); + }); + + it("fits as many chips as the single row allows, reserving overflow space", () => { + // Two 40px chips + gap + overflow reserve must fit; a third does not. + const widths = [40, 40, 40]; + const forTwo = + 40 + TAG_CHIP_GAP_PX + 40 + TAG_CHIP_GAP_PX + TAG_CHIP_OVERFLOW_WIDTH_PX; + expect(computeVisibleTagChipCount(widths, forTwo)).toBe(2); + }); + + it("drops the overflow reserve when every chip fits", () => { + const widths = [40, 40]; + const exact = 40 + TAG_CHIP_GAP_PX + 40; + expect(computeVisibleTagChipCount(widths, exact)).toBe(2); + }); + + it("returns 0 when even one chip plus overflow cannot fit", () => { + expect( + computeVisibleTagChipCount( + [40, 40], + TAG_CHIP_OVERFLOW_WIDTH_PX + TAG_CHIP_GAP_PX, + ), + ).toBe(0); + }); +}); + +describe("getDisplayConversationTags", () => { + it("excludes reserved keys and puts priority keys first", () => { + expect( + getDisplayConversationTags({ + owner: "alice", + acpserver: "claude-code", + title: "can you research an office app?", + origin: "slack", + env: "prod", + archiveworkspacepath: "/workspace/project", + git_provider: "github", + repo_name: "org/repo", + selected_branch: "main", + branch: "feature", + repo: "other/repo", + workspace: "/tmp/ws", + working_dir: "/tmp/wd", + }), + ).toEqual([ + ["origin", "slack"], + ["env", "prod"], + ["owner", "alice"], + ]); + }); + + it("ranks priority keys by their normalized name", () => { + // The reserved-key filter normalizes the key, so the priority lookup must + // too — otherwise a cloud-stamped `Origin` sorts alphabetically instead of + // leading the row. + expect( + getDisplayConversationTags({ + env: "prod", + Origin: "slack", + }), + ).toEqual([ + ["Origin", "slack"], + ["env", "prod"], + ]); + }); + + it("returns an empty list for nullish tags", () => { + expect(getDisplayConversationTags(null)).toEqual([]); + expect(getDisplayConversationTags(undefined)).toEqual([]); + }); + + it("drops blank and whitespace-only tag values", () => { + expect( + getDisplayConversationTags({ + appmode: "work", + worktools: "", + workwsid: " ", + owner: "alice", + }), + ).toEqual([ + ["appmode", "work"], + ["owner", "alice"], + ]); + }); +}); + +describe("getConversationTagLabelKind", () => { + it.each([ + ["git_provider", "git"], + // `origin` / `source` name where a conversation came from (Slack, an API + // call, an automation), which is not a git fact — they stay "other" and + // humanize to "Origin" / "Source". + ["origin", "other"], + ["source", "other"], + ["repo_name", "repo"], + ["selected_branch", "branch"], + ["archiveworkspacepath", "workspace"], + ["Appmode", "app_mode"], + ["worktools", "work_tools"], + ["Workwsid", "work_wsid"], + ["owner", "other"], + ] as const)("maps %s → %s", (key, kind) => { + expect(getConversationTagLabelKind(key)).toBe(kind); + }); +}); + +describe("getConversationTagLabel", () => { + const t = (key: I18nKey) => { + switch (key) { + case I18nKey.CONVERSATION_PANEL$PREVIEW_GIT: + return "Git"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_REPO: + return "Repo"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_BRANCH: + return "Branch"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_WORKSPACE: + return "Workspace"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_APP_MODE: + return "App mode"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_TOOLS: + return "Work tools"; + case I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_WSID: + return "Workspace ID"; + default: + return String(key); + } + }; + + it("uses localized labels for known keys instead of wire names", () => { + expect(getConversationTagLabel("selected_branch", t)).toBe("Branch"); + expect(getConversationTagLabel("repo_name", t)).toBe("Repo"); + expect(getConversationTagLabel("archiveworkspacepath", t)).toBe( + "Workspace", + ); + expect(getConversationTagLabel("Appmode", t)).toBe("App mode"); + expect(getConversationTagLabel("worktools", t)).toBe("Work tools"); + expect(getConversationTagLabel("Workwsid", t)).toBe("Workspace ID"); + expect(formatConversationTagTooltip("selected_branch", "main", t)).toBe( + "Branch: main", + ); + }); + + it("humanizes unknown snake_case keys", () => { + expect(humanizeConversationTagKey("my_custom_tag")).toBe("My custom tag"); + expect(getConversationTagLabel("owner", t)).toBe("Owner"); + }); + + it("labels origin and source by their own names, not Git", () => { + expect(getConversationTagLabel("origin", t)).toBe("Origin"); + expect(getConversationTagLabel("source", t)).toBe("Source"); + expect(formatConversationTagTooltip("origin", "slack", t)).toBe( + "Origin: slack", + ); + // The git host stamp keeps the Git label. + expect(getConversationTagLabel("git_provider", t)).toBe("Git"); + }); +}); diff --git a/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts b/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts new file mode 100644 index 0000000000..d788725a07 --- /dev/null +++ b/__tests__/components/features/conversation-panel/conversation-tag-icons.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + Briefcase, + CircleUserRound, + CloudCog, + Folder, + FolderGit2, + FolderKey, + GitBranch, + House, + Layers, + Tag, + Waypoints, + Wrench, +} from "lucide-react"; +import { FaGithub } from "react-icons/fa6"; +import SlackIcon from "#/icons/slack.svg?react"; +import { getConversationTagIcon } from "#/components/features/conversation-panel/conversation-card/conversation-tag-icons"; + +describe("getConversationTagIcon", () => { + it("maps known keys to related icons", () => { + expect(getConversationTagIcon("owner", "alice")).toBe(CircleUserRound); + expect(getConversationTagIcon("env", "prod")).toBe(CloudCog); + expect(getConversationTagIcon("repo_name", "org/repo")).toBe(FolderGit2); + expect(getConversationTagIcon("selected_branch", "main")).toBe(GitBranch); + expect( + getConversationTagIcon("archiveworkspacepath", "/workspace/project"), + ).toBe(Folder); + expect(getConversationTagIcon("worktools", "browser")).toBe(Wrench); + expect( + getConversationTagIcon("Workwsid", "cad8995a71e54b12b156d1d153be062f"), + ).toBe(FolderKey); + }); + + it("uses mode-specific icons for Appmode values", () => { + expect(getConversationTagIcon("Appmode", "work")).toBe(Briefcase); + expect(getConversationTagIcon("app_mode", "personal")).toBe(House); + expect(getConversationTagIcon("mode", "custom")).toBe(Layers); + }); + + it("prefers source brand icons for origin and git_provider values", () => { + expect(getConversationTagIcon("origin", "slack")).toBe(SlackIcon); + expect(getConversationTagIcon("git_provider", "GitHub")).toBe(FaGithub); + }); + + it("falls back to the key icon for unknown sources", () => { + expect(getConversationTagIcon("origin", "custom-bot")).toBe(Waypoints); + }); + + it("falls back to Tag for unrecognized keys", () => { + expect(getConversationTagIcon("mystery", "value")).toBe(Tag); + }); +}); diff --git a/__tests__/stores/conversation-panel-preferences-store.test.ts b/__tests__/stores/conversation-panel-preferences-store.test.ts index 26ccc461f3..fe23b77b07 100644 --- a/__tests__/stores/conversation-panel-preferences-store.test.ts +++ b/__tests__/stores/conversation-panel-preferences-store.test.ts @@ -8,12 +8,12 @@ describe("conversation-panel-preferences store", () => { window.localStorage.clear(); }); - it("defaults to showing older conversations, LLM profiles, chronological list, and expected toggles", () => { + it("defaults to showing older conversations, chronological list, and expected toggles", () => { const state = useConversationPanelPreferencesStore.getState(); expect(state.showOlderConversations).toBe(true); expect(state.showRepoBranchMetadata).toBe(false); - expect(state.showLlmProfiles).toBe(true); - expect(state.showTagsMetadata).toBe(true); + expect(state.showLlmProfiles).toBe(false); + expect(state.showTagsMetadata).toBe(false); expect(state.organizeMode).toBe("chronological"); expect(state.conversationSort).toBe("updated"); expect(state.threadScope).toBe("all"); @@ -173,21 +173,21 @@ describe("conversation-panel-preferences store", () => { showOlderConversations: false, showRepoBranchMetadata: true, // Filled with defaults for missing fields. - showLlmProfiles: true, + showLlmProfiles: false, organizeMode: "chronological", conversationSort: "updated", threadScope: "all", }); }); - it("preserves an explicitly hidden LLM-profiles preference from persisted storage", async () => { + it("preserves an explicitly enabled LLM-profiles preference from persisted storage", async () => { window.localStorage.setItem( STORAGE_KEY, JSON.stringify({ state: { showOlderConversations: true, showRepoBranchMetadata: false, - showLlmProfiles: false, + showLlmProfiles: true, }, version: 0, }), @@ -197,6 +197,6 @@ describe("conversation-panel-preferences store", () => { expect( useConversationPanelPreferencesStore.getState().showLlmProfiles, - ).toBe(false); + ).toBe(true); }); }); diff --git a/scripts/check-translation-completeness.cjs b/scripts/check-translation-completeness.cjs index b885120899..010c6d9658 100755 --- a/scripts/check-translation-completeness.cjs +++ b/scripts/check-translation-completeness.cjs @@ -23,6 +23,7 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([ 'COMMAND_MENU$SHORTCUT', 'CONVERSATION$ACP_AGENT_GENERIC', 'CONVERSATION$BUDGET_USAGE_FORMAT', + 'CONVERSATION_PANEL$PREVIEW_GIT', 'FILES$VSCODE', 'GITHUB$AUTH_SCOPE', 'LAUNCH$PLUGIN_PATH', diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index c880927a93..04da0998c1 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -452,22 +452,45 @@ export const AUTOMATION_TAG_KEYS: readonly string[] = [ ]; /** - * Conversation tag keys Canvas itself stamps/consumes for internal routing. - * They are already surfaced through dedicated UI (the ACP provider chip, the - * automation filter in the conversation panel), so the generic tag-chip - * display filters them out. The automation id/run-id keys are raw UUIDs — - * chip noise — while `automationname`/`automationtrigger` stay visible. + * Conversation tag keys that must not appear as generic chips / hovercard + * rows. Each is either already surfaced by a first-class UI source or is + * internal routing data: + * - ``acpserver`` → ACP provider chip + * - ``title`` → conversation card heading + * - git / repo / branch / workspace stamps → repo-branch metadata + directory + * footer / hovercard rows (``selected_repository``, ``selected_branch``, + * ``git_provider``, ``workspace.working_dir``) + * - ``automationid`` / ``automationrunid`` → raw UUIDs consumed by the + * conversation panel's automation filter (chip noise), while + * ``automationname`` / ``automationtrigger`` stay visible */ export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet = new Set([ ACP_SERVER_TAG_KEY, AUTOMATION_ID_TAG_KEY, AUTOMATION_RUN_ID_TAG_KEY, + "title", + "git_provider", + "repo_name", + "repo", + "repository", + "selected_branch", + "branch", + "archiveworkspacepath", + "workspace", + "working_dir", ]); +/** + * High-signal tag keys shown first in the chip row (before A–Z). Automations + * often stamp ``origin``; remaining free-form tags sort alphabetically. + */ +export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"]; + /** * User-facing subset of a conversation's server-side tags: everything except - * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries - * sorted by key so chip order doesn't shuffle between refetches. + * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries. + * Priority keys come first (in {@link PRIORITY_CONVERSATION_TAG_KEYS} order); + * the rest sort A–Z so chip order doesn't shuffle between refetches. */ export function getDisplayConversationTags( tags: Record | null | undefined, @@ -475,9 +498,31 @@ export function getDisplayConversationTags( if (!tags) { return []; } + // Both the reserved-key check and the priority lookup must see the same + // normalized key: a cloud backend can stamp ``Origin`` / `` origin``, and + // ranking those off the raw key would silently drop them out of first place. + const priorityRank = (key: string): number => { + const index = PRIORITY_CONVERSATION_TAG_KEYS.indexOf( + key.trim().toLowerCase(), + ); + return index === -1 ? Number.POSITIVE_INFINITY : index; + }; + return Object.entries(tags) - .filter(([key]) => !RESERVED_CONVERSATION_TAG_KEYS.has(key)) - .sort(([a], [b]) => a.localeCompare(b)); + .filter( + ([key, value]) => + !RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) && + typeof value === "string" && + value.trim().length > 0, + ) + .sort(([a], [b]) => { + const aRank = priorityRank(a); + const bRank = priorityRank(b); + if (aRank !== bRank) { + return aRank - bRank; + } + return a.localeCompare(b); + }); } const FERNET_TOKEN_PREFIX = "gAAAAA"; diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index 27e0cc5078..11273f3a80 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -18,13 +18,8 @@ import { import { getDisplayConversationTags } from "#/api/agent-server-adapter"; import { ConversationRepoLink } from "./conversation-repo-link"; import { NoRepository } from "./no-repository"; - -/** - * Display budget for the tag-chip row. Tags are server-/API-controlled, so a - * conversation can carry arbitrarily many; the card shows at most this many - * chips (sorted by key) and folds the rest into a "+N" overflow chip. - */ -export const MAX_VISIBLE_TAG_CHIPS = 3; +import { ConversationTagChips } from "./conversation-tag-chips"; +import { CONVERSATION_CARD_META_CHIP_CLASSNAME } from "./conversation-card-meta-chip"; interface ConversationCardFooterProps { selectedRepository: RepositorySelection | null; @@ -58,8 +53,8 @@ interface ConversationCardFooterProps { acpServer?: string | null; /** * Server-side conversation tags (``AppConversation.tags``). Non-reserved - * entries render as ``key: value`` chips so API-/automation-born - * conversations can surface attribution (e.g. ``origin: slack``). + * entries render as value-only chips so API-/automation-born + * conversations can surface attribution (e.g. ``origin: slack`` → ``slack``). */ tags?: Record | null; /** @@ -130,12 +125,6 @@ export function ConversationCardFooter({ executionStatus !== undefined ? "pl-[26px]" : undefined; const displayTags = showTags ? getDisplayConversationTags(tags) : []; - // Tags are API-controlled with no server-side count bound, so the card - // renders a fixed display budget: the first chips in sorted-key order plus - // a "+N" affordance whose tooltip reveals the remainder. The full map stays - // on ``AppConversation.tags`` for non-display consumers. - const visibleTags = displayTags.slice(0, MAX_VISIBLE_TAG_CHIPS); - const overflowTags = displayTags.slice(MAX_VISIBLE_TAG_CHIPS); return (
- {chip ? ( -
- - - {chip.text} - -
- ) : null} - {displayTags.length > 0 ? ( -
- {visibleTags.map(([key, value]) => ( - - {`${key}: ${value}`} - - ))} - {overflowTags.length > 0 ? ( - `${key}: ${value}`) - .join("\n")} - className="inline-flex items-center rounded-sm bg-[var(--oh-surface-raised)] px-1 py-px text-[10px] leading-4 text-[var(--oh-muted)]" - > - {`+${overflowTags.length}`} - - ) : null} -
- ) : null}
+ {chip ? ( +
+ + {/* Same line-height slot as tag chips; width flexes for the wider OpenHands mark. */} + + + + {chip.text} + +
+ ) : null} + {displayTags.length > 0 ? ( +
+ +
+ ) : null}
); } diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-meta-chip.ts b/src/components/features/conversation-panel/conversation-card/conversation-card-meta-chip.ts new file mode 100644 index 0000000000..c8674fe7ec --- /dev/null +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-meta-chip.ts @@ -0,0 +1,16 @@ +/** + * Shared raised-pill class for conversation-card metadata chips (LLM model, + * tags, repo, branch, workspace folder). Keep these visually identical so the + * footer reads as one chip system. + */ +export const CONVERSATION_CARD_META_CHIP_CLASSNAME = + "inline-flex max-w-full min-w-0 shrink-0 items-center gap-0.5 rounded-sm bg-[var(--oh-surface-raised)] px-1 py-px text-[10px] leading-4 text-[var(--oh-muted)]"; + +/** + * Fixed line-height icon slot matching ``leading-4`` chip text so Lucide / + * react-icons / local SVGs share the same optical center. + */ +export const CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME = + "inline-flex h-4 w-3 shrink-0 items-center justify-center [&_svg]:block"; + +export const CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME = "h-3 w-3"; diff --git a/src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx b/src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx index f033b7923f..474a5afb8f 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx @@ -9,8 +9,13 @@ import type { RepositorySelection } from "#/api/open-hands.types"; import type { Provider } from "#/types/settings"; import type { ExecutionStatus } from "#/types/agent-server/core/base/common"; import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { getDisplayConversationTags } from "#/api/agent-server-adapter"; +import { resolveAcpProviderIcon } from "#/constants/acp-providers"; import AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react"; +import { AgentBrandIcon } from "#/components/shared/agent-brand-icon"; import { ConversationStatusDot } from "../conversation-status-dot"; +import { getConversationTagLabel } from "./conversation-tag-display"; +import { getConversationTagIcon } from "./conversation-tag-icons"; interface ConversationCardPreviewProps { title: string; @@ -19,7 +24,22 @@ interface ConversationCardPreviewProps { selectedRepository: RepositorySelection | null; workspaceWorkingDir?: string | null; llmModel?: string | null; + /** + * High-level kind of the conversation's agent. Drives the model row's brand + * mark exactly like the card chip does — without it an ACP conversation + * would show the OpenHands wordmark next to a Claude Code / Codex / Gemini + * model, contradicting the chip on the very card being hovered. + */ + agentKind?: "openhands" | "acp" | null; + /** Registry key of the ACP CLI server, resolved to its brand mark. */ + acpServer?: string | null; createdAt?: string; + /** + * Server-side conversation tags. Always shown in the hovercard when present + * (except keys already covered by repository / branch / directory rows). + * Sidebar card chips stay gated by the panel's Tags preference. + */ + tags?: Record | null; } const providerIcon: Partial> = { @@ -36,15 +56,53 @@ interface PreviewRowProps { function PreviewRow({ label, children }: PreviewRowProps) { return ( -
- {label} - +
+ + {label} + + {children}
); } +/** + * Icon + value for preview rows. The icon sits in a box matching the first + * line's height (``leading-4``) so it stays optically centered when the value + * wraps to multiple lines. The slot has a 12px *minimum* rather than a fixed + * width so the wider OpenHands wordmark (18px at ``size={12}``) is not clipped, + * while the square icons still share one column. + */ +function PreviewValueWithIcon({ + icon, + children, + testId, + tagKey, +}: { + icon?: ReactNode; + children: ReactNode; + testId?: string; + tagKey?: string; +}) { + return ( + + {icon ? ( + + {icon} + + ) : null} + + {children} + + + ); +} + export function ConversationCardPreview({ title, executionStatus, @@ -52,7 +110,10 @@ export function ConversationCardPreview({ selectedRepository, workspaceWorkingDir, llmModel, + agentKind = null, + acpServer = null, createdAt, + tags = null, }: ConversationCardPreviewProps) { const { t } = useTranslation("openhands"); @@ -68,19 +129,24 @@ export function ConversationCardPreview({ }) : null; + const previewTags = getDisplayConversationTags(tags); + return ( -
+
{executionStatus !== undefined ? ( -
+ -
+ ) : null} - + {title}
@@ -88,41 +154,72 @@ export function ConversationCardPreview({
{repository ? ( <> - - - {ProviderIcon ? ( - - ) : null} - {provider === "azure_devops" ? ( - - ) : null} - {repository} - + + + ) : provider === "azure_devops" ? ( + + ) : undefined + } + > + {repository} + {branch ? ( - - - - {branch} - + + }> + {branch} + ) : null} ) : workspaceWorkingDir ? ( - - - {workspaceWorkingDir} - + }> + {workspaceWorkingDir} + ) : null} {llmModel ? ( - {llmModel} + + } + > + {llmModel} + ) : null} + {previewTags.map(([key, value]) => { + const Icon = getConversationTagIcon(key, value); + const label = getConversationTagLabel(key, t); + + return ( + + } + > + {value} + + + ); + })} + {createdLabel ? ( {createdLabel} diff --git a/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx b/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx index 98340a5d58..b8290b52ab 100644 --- a/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx +++ b/src/components/features/conversation-panel/conversation-card/conversation-repo-link.tsx @@ -4,6 +4,23 @@ import { IconType } from "react-icons/lib"; import { RepositorySelection } from "#/api/open-hands.types"; import { Provider } from "#/types/settings"; import AzureDevOpsLogo from "#/assets/branding/azure-devops-logo.svg?react"; +import { cn } from "#/utils/utils"; +import { + CONVERSATION_CARD_META_CHIP_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME, +} from "./conversation-card-meta-chip"; + +/** + * Repo and branch share one ``overflow-hidden`` row, so unlike the standalone + * chips they must be allowed to shrink: with the shared class's ``shrink-0`` + * a long repository name claims the full row and pushes the branch chip out of + * view entirely. Shrinking lets both stay visible and truncate proportionally. + */ +const REPO_LINK_CHIP_CLASSNAME = cn( + CONVERSATION_CARD_META_CHIP_CLASSNAME, + "shrink", +); interface ConversationRepoLinkProps { selectedRepository: RepositorySelection; @@ -22,31 +39,51 @@ export function ConversationRepoLink({ const Icon = selectedRepository.git_provider ? providerIcon[selectedRepository.git_provider] : null; + const repository = selectedRepository.selected_repository; + const branch = selectedRepository.selected_branch; return ( -
-
- {Icon && } - {selectedRepository.git_provider === "azure_devops" && ( - - )} +
+ {repository ? ( - {selectedRepository.selected_repository} + {(Icon || selectedRepository.git_provider === "azure_devops") && ( + + {Icon ? ( + + ) : ( + + )} + + )} + {repository} -
-
- - + ) : null} + {branch ? ( - {selectedRepository.selected_branch} + + + + {branch} -
+ ) : null}
); } diff --git a/src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx b/src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx new file mode 100644 index 0000000000..a974ceb8fa --- /dev/null +++ b/src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx @@ -0,0 +1,336 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + computeVisibleTagChipCount, + formatConversationTagTooltip, + getConversationTagLabel, + truncateTagChipValue, +} from "./conversation-tag-display"; +import { + getConversationTagIcon, + type ConversationTagIcon, +} from "./conversation-tag-icons"; +import { + CONVERSATION_CARD_META_CHIP_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME, +} from "./conversation-card-meta-chip"; + +interface ConversationTagChipsProps { + tags: Array<[string, string]>; +} + +/** + * Fixed line-height slot so Lucide / react-icons / local SVGs share the same + * optical center as the ``leading-4`` chip / overflow text (raw ``h-3 w-3`` + * SVGs sit on the baseline and look high or low depending on the glyph). + */ +function TagIconSlot({ + icon: Icon, + keyName, + testId, +}: { + icon: ConversationTagIcon; + keyName?: string; + testId?: string; +}) { + return ( + + + + ); +} + +function TagChipContent({ + icon, + keyName, + value, + iconTestId, +}: { + icon: ConversationTagIcon; + keyName: string; + value: string; + /** + * Only the visible row passes this. The off-screen measure row renders the + * same chips, so tagging both would emit every chip test id twice and break + * ``getByTestId`` (singular) for any card with tags. + */ + iconTestId?: string; +}) { + return ( + <> + + {truncateTagChipValue(value)} + + ); +} + +/** + * Single-row tag chips for a conversation card. Chip labels are value-only + * (key lives in the tooltip); chips that do not fit fold behind a ``+N`` + * button that opens a key/value popover. + * + * The overflow popover is portaled with ``position: fixed`` so it is not + * clipped by the chip row's ``overflow-hidden`` or the sidebar scroller. + */ +export function ConversationTagChips({ tags }: ConversationTagChipsProps) { + const { t } = useTranslation("openhands"); + const containerRef = React.useRef(null); + const measureRef = React.useRef(null); + const triggerRef = React.useRef(null); + const popoverRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState(tags.length); + const [isOverflowOpen, setIsOverflowOpen] = React.useState(false); + const [popoverBox, setPopoverBox] = React.useState<{ + top: number; + left: number; + } | null>(null); + + const recomputeVisibleCount = React.useCallback(() => { + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure) { + return; + } + + const widths = Array.from(measure.children).map( + (child) => (child as HTMLElement).offsetWidth, + ); + setVisibleCount(computeVisibleTagChipCount(widths, container.clientWidth)); + }, []); + + const measurePopover = React.useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const rect = trigger.getBoundingClientRect(); + const gutter = 8; + const width = 16 * 16; + let left = rect.left; + if (left + width > window.innerWidth - gutter) { + left = Math.max(gutter, window.innerWidth - gutter - width); + } + setPopoverBox({ top: rect.bottom + 4, left }); + }, []); + + // Callers build this array fresh on every render (``getDisplayConversationTags`` + // returns a new array), so keying the reset effect on ``tags`` identity would + // fire on every parent render — closing an open ``+N`` popover and forcing a + // layout read on each 10s panel refetch. Compare tag *content* instead. + const tagsKey = tags.map(([key, value]) => `${key}=${value}`).join("\u001f"); + const lastTagsKeyRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (lastTagsKeyRef.current === tagsKey) { + return; + } + lastTagsKeyRef.current = tagsKey; + setIsOverflowOpen(false); + recomputeVisibleCount(); + }, [tagsKey, recomputeVisibleCount]); + + React.useEffect(() => { + const container = containerRef.current; + if (!container || typeof ResizeObserver === "undefined") { + return undefined; + } + + const observer = new ResizeObserver(() => recomputeVisibleCount()); + observer.observe(container); + return () => observer.disconnect(); + }, [recomputeVisibleCount]); + + React.useLayoutEffect(() => { + if (!isOverflowOpen) { + setPopoverBox(null); + return undefined; + } + measurePopover(); + window.addEventListener("resize", measurePopover); + window.addEventListener("scroll", measurePopover, true); + return () => { + window.removeEventListener("resize", measurePopover); + window.removeEventListener("scroll", measurePopover, true); + }; + }, [isOverflowOpen, measurePopover]); + + React.useEffect(() => { + if (!isOverflowOpen) { + return undefined; + } + const onPointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (triggerRef.current?.contains(target)) { + return; + } + if (popoverRef.current?.contains(target)) { + return; + } + setIsOverflowOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOverflowOpen(false); + } + }; + // mousedown (not click) so the opening click cannot race-close the panel, + // and so NavigationLink activation is easier to cancel on the trigger. + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isOverflowOpen]); + + if (tags.length === 0) { + return null; + } + + const hiddenCount = Math.max(0, tags.length - visibleCount); + const visibleTags = tags.slice(0, visibleCount); + const overflowTags = tags.slice(visibleCount); + + const stopCardNavigation = (event: React.SyntheticEvent) => { + // Don't preventDefault on mousedown — that suppresses the subsequent click + // when the chip sits inside a conversation NavigationLink. + event.stopPropagation(); + }; + + const activateOverflow = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsOverflowOpen((open) => !open); + }; + + return ( +
+
+ {tags.map(([key, value]) => ( + + + + ))} +
+ +
+ {visibleTags.map(([key, value]) => ( + + + + ))} + {hiddenCount > 0 ? ( + + ) : null} +
+ + {isOverflowOpen && + popoverBox && + typeof document !== "undefined" && + createPortal( + // Stop card-level click activation when interacting with the list. + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- dialog surface must swallow clicks +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > +
+ {overflowTags.map(([key, value]) => ( +
+
+ + + {getConversationTagLabel(key, t)} + +
+
+ {value} +
+
+ ))} +
+
, + document.body, + )} +
+ ); +} diff --git a/src/components/features/conversation-panel/conversation-card/conversation-tag-display.ts b/src/components/features/conversation-panel/conversation-card/conversation-tag-display.ts new file mode 100644 index 0000000000..72d08458a5 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-card/conversation-tag-display.ts @@ -0,0 +1,189 @@ +/** + * Display helpers for conversation tag chips. Pure (no React) so fit/truncate + * behavior can be unit-tested without laying out the sidebar card. + */ + +import { I18nKey } from "#/i18n/declaration"; + +/** Max characters shown on a chip before hard truncation with an ellipsis. */ +export const TAG_CHIP_VALUE_MAX_LENGTH = 14; + +/** Horizontal gap between chips (matches Tailwind ``gap-1`` = 4px). */ +export const TAG_CHIP_GAP_PX = 4; + +/** + * Reserved width for the ``+N`` overflow control when deciding how many chips + * fit on one row. Slightly generous so the button never wraps under a chip. + */ +export const TAG_CHIP_OVERFLOW_WIDTH_PX = 36; + +/** Known tag keys that already have a dedicated hovercard label. */ +export type ConversationTagLabelKind = + | "git" + | "repo" + | "branch" + | "workspace" + | "app_mode" + | "work_tools" + | "work_wsid" + | "other"; + +/** + * Map a server tag key to a hovercard label kind. ``archiveworkspacepath`` is + * treated as "workspace" — the sandbox working directory for the conversation. + * ACM / Work stamps (``Appmode``, ``Worktools``, ``Workwsid``) get dedicated + * kinds so chips and hovercards show friendly labels instead of the wire key. + * + * ``origin`` / ``source`` deliberately stay "other" (humanized to "Origin" / + * "Source"): they name where a conversation came from — Slack, an API call, an + * automation — which is not a git fact. Only ``git_provider`` is "Git". + */ +export function getConversationTagLabelKind( + key: string, +): ConversationTagLabelKind { + switch (key.trim().toLowerCase()) { + case "git_provider": + return "git"; + case "repo_name": + case "repo": + case "repository": + return "repo"; + case "selected_branch": + case "branch": + return "branch"; + case "archiveworkspacepath": + case "workspace": + case "working_dir": + return "workspace"; + case "appmode": + case "app_mode": + case "mode": + return "app_mode"; + case "worktools": + case "work_tools": + case "tools": + return "work_tools"; + case "workwsid": + case "work_wsid": + case "wsid": + case "workspace_id": + return "work_wsid"; + default: + return "other"; + } +} + +/** + * Soften unknown snake_case / kebab-case keys for tooltips and overflow rows + * (``selected_branch`` is mapped above; this covers free-form keys like + * ``env`` → ``Env``). + */ +export function humanizeConversationTagKey(key: string): string { + const trimmed = key.trim(); + if (!trimmed) { + return trimmed; + } + const words = trimmed.replace(/[_-]+/g, " ").replace(/\s+/g, " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** + * Localized label for a tag key (chip tooltip, overflow popover, hovercard). + * Known keys use the preview copy; everything else is humanized. + */ +export function getConversationTagLabel( + key: string, + t: (key: I18nKey) => string, +): string { + switch (getConversationTagLabelKind(key)) { + case "git": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_GIT); + case "repo": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_REPO); + case "branch": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_BRANCH); + case "workspace": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_WORKSPACE); + case "app_mode": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_APP_MODE); + case "work_tools": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_TOOLS); + case "work_wsid": + return t(I18nKey.CONVERSATION_PANEL$PREVIEW_WORK_WSID); + default: + return humanizeConversationTagKey(key); + } +} + +/** ``Branch: main`` — used by chip ``title`` tooltips. */ +export function formatConversationTagTooltip( + key: string, + value: string, + t: (key: I18nKey) => string, +): string { + return `${getConversationTagLabel(key, t)}: ${value}`; +} + +/** + * Hard-truncate a tag value for the chip label. The full ``key: value`` string + * stays available via tooltip / overflow popover. + * + * Measures and slices by code point rather than UTF-16 code unit: values + * stamped by Slack / Discord automations carry emoji, and cutting between the + * halves of a surrogate pair leaves a lone surrogate that browsers draw as a + * replacement glyph. + */ +export function truncateTagChipValue( + value: string, + maxLength: number = TAG_CHIP_VALUE_MAX_LENGTH, +): string { + const characters = Array.from(value); + if (characters.length <= maxLength) { + return value; + } + if (maxLength <= 1) { + return "…"; + } + return `${characters.slice(0, maxLength - 1).join("")}…`; +} + +/** + * How many chips fit in ``containerWidth`` while keeping a single nowrap row + * and reserving space for a ``+N`` overflow control when any chips would hide. + * + * Returns ``widths.length`` when ``containerWidth <= 0`` (not laid out yet / + * jsdom) so callers can show every chip until a real measurement arrives. + * Returns ``0`` when the row is too narrow for even one chip + overflow — the + * UI then shows only the ``+N`` control with the full list in the popover. + */ +export function computeVisibleTagChipCount( + widths: number[], + containerWidth: number, + options: { + gapPx?: number; + overflowWidthPx?: number; + } = {}, +): number { + const gapPx = options.gapPx ?? TAG_CHIP_GAP_PX; + const overflowWidthPx = options.overflowWidthPx ?? TAG_CHIP_OVERFLOW_WIDTH_PX; + + if (widths.length === 0) { + return 0; + } + if (containerWidth <= 0) { + return widths.length; + } + + let used = 0; + for (let i = 0; i < widths.length; i += 1) { + const width = widths[i]!; + const gap = i > 0 ? gapPx : 0; + const remaining = widths.length - i - 1; + const reserve = remaining > 0 ? overflowWidthPx + gapPx : 0; + if (used + gap + width + reserve > containerWidth) { + return i; + } + used += gap + width; + } + return widths.length; +} diff --git a/src/components/features/conversation-panel/conversation-card/conversation-tag-icons.ts b/src/components/features/conversation-panel/conversation-card/conversation-tag-icons.ts new file mode 100644 index 0000000000..c020a9bd5b --- /dev/null +++ b/src/components/features/conversation-panel/conversation-card/conversation-tag-icons.ts @@ -0,0 +1,176 @@ +import type { ComponentType, SVGProps } from "react"; +import { + Bot, + Briefcase, + Building2, + CircleUserRound, + CloudCog, + Flag, + Folder, + FolderGit2, + FolderKey, + GitBranch, + GitPullRequest, + Globe2, + Hash, + House, + IdCard, + KeyRound, + Layers, + Link2, + Mails, + MessagesSquare, + Plug, + SquareKanban, + Tag, + Ticket, + UsersRound, + Waypoints, + Webhook, + Wrench, + Zap, + type LucideIcon, +} from "lucide-react"; +import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6"; +import type { IconType } from "react-icons/lib"; +import SlackIcon from "#/icons/slack.svg?react"; + +/** + * Any icon renderable inside a tag chip / overflow row. Lucide, react-icons, + * and local SVG React components all work as long as they accept ``className`` + * and inherit ``currentColor`` for the muted chip text. + */ +export type ConversationTagIcon = + | LucideIcon + | IconType + | ComponentType>; + +/** + * Tag keys whose value names a git host / chat source — resolve the icon from + * the value (e.g. ``git_provider: github`` → GitHub mark) instead of the key. + */ +const SOURCE_VALUE_DRIVEN_TAG_KEYS = new Set([ + "origin", + "source", + "git_provider", +]); + +/** + * App-mode keys resolve from the mode value (``work`` → briefcase) when known, + * otherwise fall back to {@link Layers}. + */ +const APP_MODE_TAG_KEYS = new Set(["appmode", "app_mode", "mode"]); + +/** + * Icons for well-known conversation tag keys. Unknown keys fall back to + * {@link Tag}. Value-driven keys additionally resolve via the value maps below. + */ +const KEY_ICONS: Record = { + origin: Waypoints, + source: Link2, + git_provider: FaGithub, + owner: CircleUserRound, + user: CircleUserRound, + author: CircleUserRound, + assignee: CircleUserRound, + env: CloudCog, + environment: CloudCog, + repo: FolderGit2, + repository: FolderGit2, + repo_name: FolderGit2, + branch: GitBranch, + selected_branch: GitBranch, + archiveworkspacepath: Folder, + workspace: Folder, + working_dir: Folder, + team: UsersRound, + org: Building2, + organization: Building2, + channel: Hash, + email: Mails, + automation: Zap, + webhook: Webhook, + agent: Bot, + project: SquareKanban, + ticket: Ticket, + issue: Ticket, + pr: GitPullRequest, + pull_request: GitPullRequest, + priority: Flag, + status: IdCard, + id: KeyRound, + integration: Plug, + appmode: Layers, + app_mode: Layers, + mode: Layers, + worktools: Wrench, + work_tools: Wrench, + tools: Wrench, + workwsid: FolderKey, + work_wsid: FolderKey, + wsid: FolderKey, + workspace_id: FolderKey, +}; + +/** + * Value-specific icons for source / provider stamps (``origin``, + * ``git_provider``, …), keyed by the lowercase stamp value. + */ +const SOURCE_VALUE_ICONS: Record = { + slack: SlackIcon, + discord: MessagesSquare, + github: FaGithub, + gitlab: FaGitlab, + bitbucket: FaBitbucket, + bitbucket_data_center: FaBitbucket, + azure_devops: GitBranch, + email: Mails, + mail: Mails, + api: Plug, + webhook: Webhook, + automation: Zap, + review: GitPullRequest, + linear: SquareKanban, + web: Globe2, + ui: Globe2, + canvas: Globe2, +}; + +/** + * Value-specific icons for app-mode stamps (``Appmode: work`` → briefcase). + */ +const APP_MODE_VALUE_ICONS: Record = { + work: Briefcase, + personal: House, + home: House, + private: House, +}; + +/** + * Pick an icon that matches a conversation tag. Prefer value-specific icons + * for source/provider and app-mode keys; otherwise map by key; finally fall + * back to ``Tag``. + */ +export function getConversationTagIcon( + key: string, + value: string, +): ConversationTagIcon { + const normalizedKey = key.trim().toLowerCase(); + const normalizedValue = value.trim().toLowerCase(); + + if (SOURCE_VALUE_DRIVEN_TAG_KEYS.has(normalizedKey)) { + return ( + SOURCE_VALUE_ICONS[normalizedValue] ?? KEY_ICONS[normalizedKey] ?? Tag + ); + } + + if (APP_MODE_TAG_KEYS.has(normalizedKey)) { + return ( + APP_MODE_VALUE_ICONS[normalizedValue] ?? + KEY_ICONS[normalizedKey] ?? + Layers + ); + } + + return KEY_ICONS[normalizedKey] ?? Tag; +} diff --git a/src/components/features/conversation-panel/conversation-card/no-repository.tsx b/src/components/features/conversation-panel/conversation-card/no-repository.tsx index 5070b41fc6..22568226e9 100644 --- a/src/components/features/conversation-panel/conversation-card/no-repository.tsx +++ b/src/components/features/conversation-panel/conversation-card/no-repository.tsx @@ -1,7 +1,13 @@ import { useTranslation } from "react-i18next"; +import { Folder } from "lucide-react"; import { I18nKey } from "#/i18n/declaration"; import RepoForkedIcon from "#/icons/repo-forked.svg?react"; import { getPathBasename } from "#/utils/path-utils"; +import { + CONVERSATION_CARD_META_CHIP_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME, + CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME, +} from "./conversation-card-meta-chip"; interface NoRepositoryProps { workspaceWorkingDir?: string | null; @@ -17,24 +23,37 @@ export function NoRepository({ workspaceWorkingDir }: NoRepositoryProps) { if (folderName) { return ( - {folderName} + + + + {folderName} ); } return ( -
- - + + + + + {t(I18nKey.COMMON$NO_REPOSITORY)} -
+ ); } diff --git a/src/components/features/conversation-panel/conversation-panel-filter-menu.tsx b/src/components/features/conversation-panel/conversation-panel-filter-menu.tsx index 172e37cc2a..b726d379f7 100644 --- a/src/components/features/conversation-panel/conversation-panel-filter-menu.tsx +++ b/src/components/features/conversation-panel/conversation-panel-filter-menu.tsx @@ -323,16 +323,6 @@ export function ConversationPanelFilterMenu({ {t(I18nKey.CONVERSATION_PANEL$METADATA)} - { - toggleShowLlmProfiles(); - setFilterMenuOpen(false); - }} - /> + { + toggleShowLlmProfiles(); + setFilterMenuOpen(false); + }} + /> } > diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 8899e70ee0..789849d3f0 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -37212,6 +37212,142 @@ "uk": "Змінити порядок {{name}}", "ca": "Reordenar {{name}}" }, + "CONVERSATION$TAGS_OVERFLOW_ARIA": { + "en": "Show {{count}} more tags", + "ja": "他に{{count}}件のタグを表示", + "zh-CN": "显示另外 {{count}} 个标签", + "zh-TW": "顯示另外 {{count}} 個標籤", + "ko-KR": "태그 {{count}}개 더 보기", + "no": "Vis {{count}} flere etiketter", + "ar": "عرض {{count}} وسوم إضافية", + "de": "{{count}} weitere Tags anzeigen", + "fr": "Afficher {{count}} étiquettes de plus", + "it": "Mostra altri {{count}} tag", + "pt": "Mostrar mais {{count}} etiquetas", + "es": "Mostrar {{count}} etiquetas más", + "ca": "Mostra {{count}} etiquetes més", + "tr": "{{count}} etiket daha göster", + "uk": "Показати ще {{count}} тегів" + }, + "CONVERSATION_PANEL$PREVIEW_GIT": { + "en": "Git", + "ja": "Git", + "zh-CN": "Git", + "zh-TW": "Git", + "ko-KR": "Git", + "no": "Git", + "ar": "Git", + "de": "Git", + "fr": "Git", + "it": "Git", + "pt": "Git", + "es": "Git", + "ca": "Git", + "tr": "Git", + "uk": "Git" + }, + "CONVERSATION_PANEL$PREVIEW_REPO": { + "en": "Repo", + "ja": "リポジトリ", + "zh-CN": "仓库", + "zh-TW": "倉庫", + "ko-KR": "저장소", + "no": "Repo", + "ar": "المستودع", + "de": "Repo", + "fr": "Dépôt", + "it": "Repo", + "pt": "Repo", + "es": "Repo", + "ca": "Repo", + "tr": "Depo", + "uk": "Репо" + }, + "CONVERSATION_PANEL$PREVIEW_BRANCH": { + "en": "Branch", + "ja": "ブランチ", + "zh-CN": "分支", + "zh-TW": "分支", + "ko-KR": "브랜치", + "no": "Gren", + "ar": "الفرع", + "de": "Branch", + "fr": "Branche", + "it": "Ramo", + "pt": "Ramo", + "es": "Rama", + "ca": "Branca", + "tr": "Dal", + "uk": "Гілка" + }, + "CONVERSATION_PANEL$PREVIEW_WORKSPACE": { + "en": "Workspace", + "ja": "ワークスペース", + "zh-CN": "工作区", + "zh-TW": "工作區", + "ko-KR": "워크스페이스", + "no": "Arbeidsområde", + "ar": "مساحة العمل", + "de": "Arbeitsbereich", + "fr": "Espace de travail", + "it": "Workspace", + "pt": "Espaço de trabalho", + "es": "Espacio de trabajo", + "ca": "Espai de treball", + "tr": "Çalışma alanı", + "uk": "Робочий простір" + }, + "CONVERSATION_PANEL$PREVIEW_APP_MODE": { + "en": "App mode", + "ja": "アプリモード", + "zh-CN": "应用模式", + "zh-TW": "應用模式", + "ko-KR": "앱 모드", + "no": "App-modus", + "ar": "وضع التطبيق", + "de": "App-Modus", + "fr": "Mode app", + "it": "Modalità app", + "pt": "Modo do app", + "es": "Modo de la app", + "ca": "Mode de l'app", + "tr": "Uygulama modu", + "uk": "Режим застосунку" + }, + "CONVERSATION_PANEL$PREVIEW_WORK_TOOLS": { + "en": "Work tools", + "ja": "ワークツール", + "zh-CN": "工作工具", + "zh-TW": "工作工具", + "ko-KR": "작업 도구", + "no": "Arbeidsverktøy", + "ar": "أدوات العمل", + "de": "Arbeitswerkzeuge", + "fr": "Outils de travail", + "it": "Strumenti di lavoro", + "pt": "Ferramentas de trabalho", + "es": "Herramientas de trabajo", + "ca": "Eines de treball", + "tr": "Çalışma araçları", + "uk": "Робочі інструменти" + }, + "CONVERSATION_PANEL$PREVIEW_WORK_WSID": { + "en": "Workspace ID", + "ja": "ワークスペース ID", + "zh-CN": "工作区 ID", + "zh-TW": "工作區 ID", + "ko-KR": "워크스페이스 ID", + "no": "Arbeidsområde-ID", + "ar": "معرّف مساحة العمل", + "de": "Arbeitsbereichs-ID", + "fr": "ID d'espace de travail", + "it": "ID workspace", + "pt": "ID do espaço de trabalho", + "es": "ID del espacio de trabajo", + "ca": "ID de l'espai de treball", + "tr": "Çalışma alanı kimliği", + "uk": "ID робочого простору" + }, "SETTINGS$VERSION_NPM_RECOMMENDED": { "en": "npm", "ja": "npm", diff --git a/src/stores/conversation-panel-preferences-store.ts b/src/stores/conversation-panel-preferences-store.ts index fe0e79237e..383f97bb39 100644 --- a/src/stores/conversation-panel-preferences-store.ts +++ b/src/stores/conversation-panel-preferences-store.ts @@ -59,8 +59,8 @@ type ConversationPanelPreferencesStore = ConversationPanelPreferencesState & const initialState: ConversationPanelPreferencesState = { showOlderConversations: true, showRepoBranchMetadata: false, - showLlmProfiles: true, - showTagsMetadata: true, + showLlmProfiles: false, + showTagsMetadata: false, showHoverMetadata: true, organizeMode: "chronological", conversationSort: "updated",