From 52fb583107eb01667a8d8a76eca6f2ac3f1dcc2b Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Fri, 7 Aug 2026 07:07:55 -0700 Subject: [PATCH] feat(conversations): add tag chips, overflow, and hovercard labels (#16346) Co-authored-by: Cursor Co-authored-by: hieptl --- .../conversation-list-tags.png | Bin 0 -> 41315 bytes .../conversation-card-preview.test.tsx | 145 ++++++++ .../conversation-card.test.tsx | 197 +++++++--- .../conversation-panel-filter-menu.test.tsx | 15 + .../conversation-tag-chips.test.tsx | 269 ++++++++++++++ .../conversation-tag-display.test.ts | 215 +++++++++++ .../conversation-tag-icons.test.ts | 53 +++ ...nversation-panel-preferences-store.test.ts | 14 +- scripts/check-translation-completeness.cjs | 1 + src/api/agent-server-adapter.ts | 63 +++- .../conversation-card-footer.tsx | 84 ++--- .../conversation-card-meta-chip.ts | 16 + .../conversation-card-preview.tsx | 151 ++++++-- .../conversation-repo-link.tsx | 67 +++- .../conversation-tag-chips.tsx | 336 ++++++++++++++++++ .../conversation-tag-display.ts | 189 ++++++++++ .../conversation-tag-icons.ts | 176 +++++++++ .../conversation-card/no-repository.tsx | 39 +- .../conversation-panel-filter-menu.tsx | 20 +- .../conversation-panel/conversation-panel.tsx | 5 +- src/i18n/translation.json | 136 +++++++ .../conversation-panel-preferences-store.ts | 4 +- 22 files changed, 2005 insertions(+), 190 deletions(-) create mode 100644 .pr/conversation-panel-tags/conversation-list-tags.png create mode 100644 __tests__/components/features/conversation-panel/conversation-card-preview.test.tsx create mode 100644 __tests__/components/features/conversation-panel/conversation-tag-chips.test.tsx create mode 100644 __tests__/components/features/conversation-panel/conversation-tag-display.test.ts create mode 100644 __tests__/components/features/conversation-panel/conversation-tag-icons.test.ts create mode 100644 src/components/features/conversation-panel/conversation-card/conversation-card-meta-chip.ts create mode 100644 src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx create mode 100644 src/components/features/conversation-panel/conversation-card/conversation-tag-display.ts create mode 100644 src/components/features/conversation-panel/conversation-card/conversation-tag-icons.ts 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 0000000000000000000000000000000000000000..bd24db186edd608c6ad26864837a6bc12d29c57b GIT binary patch literal 41315 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

OqS=8=YnOp3TXc$@s7H_yRbOz2~D!s~7D! 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 literal 0 HcmV?d00001 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",