aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-09 00:09:10 +0000
committerGitHub <noreply@github.com>2026-02-09 00:09:10 +0000
commit4186c4c64c68892248ce8671d9b8e67fc7f884a0 (patch)
tree91bbbfc0bb47a966b9e340fdbe2a61b2e10ebd19 /apps/web/components/dashboard
parent77b186c3a599297da0cf19e923c66607ad7d74e7 (diff)
downloadkarakeep-4186c4c64c68892248ce8671d9b8e67fc7f884a0.tar.zst
feat(ai): Support restricting AI tags to a subset of existing tags (#2444)
* feat(ai): Support restricting AI tags to a subset of existing tags Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx57
1 files changed, 50 insertions, 7 deletions
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index 45fae173..ec4a9d8a 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -13,6 +13,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useClientConfig } from "@/lib/clientConfig";
+import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Command as CommandPrimitive } from "cmdk";
@@ -26,13 +27,18 @@ export function TagsEditor({
onAttach,
onDetach,
disabled,
+ allowCreation = true,
+ placeholder,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
disabled?: boolean;
+ allowCreation?: boolean;
+ placeholder?: string;
}) {
const api = useTRPC();
+ const { t } = useTranslation();
const demoMode = !!useClientConfig().demoMode;
const isDisabled = demoMode || disabled;
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -41,6 +47,7 @@ export function TagsEditor({
const [inputValue, setInputValue] = React.useState("");
const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags);
const tempIdCounter = React.useRef(0);
+ const hasInitializedRef = React.useRef(_tags.length > 0);
const generateTempId = React.useCallback(() => {
tempIdCounter.current += 1;
@@ -55,22 +62,39 @@ export function TagsEditor({
}, []);
React.useEffect(() => {
+ // When allowCreation is false, only sync on initial load
+ // After that, rely on optimistic updates to avoid re-ordering
+ if (!allowCreation) {
+ if (!hasInitializedRef.current && _tags.length > 0) {
+ hasInitializedRef.current = true;
+ setOptimisticTags(_tags);
+ }
+ return;
+ }
+
+ // For allowCreation mode, sync server state with optimistic state
setOptimisticTags((prev) => {
- let results = prev;
+ // Start with a copy to avoid mutating the previous state
+ const results = [...prev];
+ let changed = false;
+
for (const tag of _tags) {
const idx = results.findIndex((t) => t.name === tag.name);
if (idx == -1) {
results.push(tag);
+ changed = true;
continue;
}
if (results[idx].id.startsWith("temp-")) {
results[idx] = tag;
+ changed = true;
continue;
}
}
- return results;
+
+ return changed ? results : prev;
});
- }, [_tags]);
+ }, [_tags, allowCreation]);
const { data: filteredOptions, isLoading: isExistingTagsLoading } = useQuery(
api.tags.list.queryOptions(
@@ -124,7 +148,7 @@ export function TagsEditor({
(opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(),
);
- if (!exactMatch) {
+ if (!exactMatch && allowCreation) {
return [
{
id: "create-new",
@@ -138,7 +162,7 @@ export function TagsEditor({
}
return baseOptions;
- }, [filteredOptions, trimmedInputValue]);
+ }, [filteredOptions, trimmedInputValue, allowCreation]);
const onChange = (
actionMeta:
@@ -258,6 +282,24 @@ export function TagsEditor({
}
};
+ const inputPlaceholder =
+ placeholder ??
+ (allowCreation
+ ? t("tags.search_or_create_placeholder", {
+ defaultValue: "Search or create tags...",
+ })
+ : t("tags.search_placeholder", {
+ defaultValue: "Search tags...",
+ }));
+ const visiblePlaceholder =
+ optimisticTags.length === 0 ? inputPlaceholder : undefined;
+ const inputWidth = Math.max(
+ inputValue.length > 0
+ ? inputValue.length
+ : Math.min(visiblePlaceholder?.length ?? 1, 24),
+ 1,
+ );
+
return (
<div ref={containerRef} className="w-full">
<Popover open={open && !isDisabled} onOpenChange={handleOpenChange}>
@@ -313,8 +355,9 @@ export function TagsEditor({
value={inputValue}
onKeyDown={handleKeyDown}
onValueChange={(v) => setInputValue(v)}
+ placeholder={visiblePlaceholder}
className="bg-transparent outline-none placeholder:text-muted-foreground"
- style={{ width: `${Math.max(inputValue.length, 1)}ch` }}
+ style={{ width: `${inputWidth}ch` }}
disabled={isDisabled}
/>
{isExistingTagsLoading && (
@@ -331,7 +374,7 @@ export function TagsEditor({
<CommandList className="max-h-64">
{displayedOptions.length === 0 ? (
<CommandEmpty>
- {trimmedInputValue ? (
+ {trimmedInputValue && allowCreation ? (
<div className="flex items-center justify-between px-2 py-1.5">
<span>Create &quot;{trimmedInputValue}&quot;</span>
<Button