@agent-native/core 0.95.0 → 0.95.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +10 -0
- package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +13 -0
- package/corpus/core/docs/content/locales/de-DE/template-design.mdx +18 -0
- package/corpus/core/docs/content/locales/es-ES/template-design.mdx +16 -0
- package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +17 -0
- package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +16 -0
- package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +17 -0
- package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +15 -0
- package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +16 -0
- package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +11 -0
- package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +12 -0
- package/corpus/core/docs/content/template-design.mdx +16 -0
- package/corpus/core/package.json +2 -2
- package/corpus/core/src/agent/production-agent.ts +20 -0
- package/corpus/core/src/agent/thread-data-builder.ts +41 -17
- package/corpus/core/src/client/AssistantChat.tsx +250 -25
- package/corpus/core/src/client/chat/tool-call-display.tsx +1 -1
- package/corpus/core/src/client/chat/use-reconnect-reader-owner.ts +28 -0
- package/corpus/core/src/client/guided-questions.tsx +9 -1
- package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +6 -7
- package/corpus/core/src/client/sse-event-processor.ts +90 -21
- package/corpus/core/src/connections/catalog.ts +2 -2
- package/corpus/core/src/deploy/build.ts +3 -0
- package/corpus/core/src/integrations/catalog.ts +2 -2
- package/corpus/core/src/server/auth.ts +1 -1
- package/corpus/core/src/server/credential-provider.ts +7 -3
- package/corpus/core/src/shared/cache-control.ts +10 -4
- package/corpus/templates/analytics/app/lib/data-sources.ts +5 -4
- package/corpus/templates/analytics/changelog/2026-07-10-slack-token-setup-marked-legacy.md +6 -0
- package/corpus/templates/analytics/server/lib/credential-keys.ts +6 -2
- package/corpus/templates/brain/changelog/2026-07-10-slack-backfill-token-marked-legacy.md +6 -0
- package/corpus/templates/brain/server/register-secrets.ts +2 -2
- package/corpus/templates/clips/app/components/library/library-layout.tsx +7 -1
- package/corpus/templates/clips/app/components/library/page-header.tsx +19 -3
- package/corpus/templates/clips/app/routes/_app.library._index.tsx +16 -12
- package/corpus/templates/clips/changelog/2026-07-10-new-recording-appears-once-when-the-library-sidebar-is-open.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-slack-token-fallback-guidance.md +6 -0
- package/corpus/templates/clips/server/register-secrets.ts +1 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +0 -66
- package/corpus/templates/content/app/routes/_app.local-files.tsx +7 -47
- package/corpus/templates/content/changelog/2026-07-10-local-folders-now-wait-for-an-explicit-pull-before-content-r.md +6 -0
- package/corpus/templates/content/package.json +1 -1
- package/corpus/templates/design/actions/generate-design.ts +128 -31
- package/corpus/templates/design/actions/present-design-variants.ts +37 -9
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +9 -3
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +32 -10
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +2 -2
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +9 -1
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +7 -5
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
- package/corpus/templates/design/app/global.css +10 -0
- package/corpus/templates/design/app/hooks/use-question-flow.ts +9 -2
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +21 -11
- package/corpus/templates/design/changelog/2026-07-10-editor-panels-are-easier-to-distinguish-and-design-intake-qu.md +6 -0
- package/corpus/templates/design/changelog/2026-07-10-interact-mode-now-stays-on-the-full-canvas-matches-edit-visu.md +6 -0
- package/corpus/templates/design/changelog/2026-07-10-new-web-designs-open-at-desktop-size-and-adapt-across-mobile.md +6 -0
- package/corpus/templates/design/changelog/2026-07-10-tweaks-now-link-to-full-documentation.md +6 -0
- package/corpus/templates/design/server/plugins/agent-chat.ts +2 -0
- package/corpus/templates/mail/app/i18n/ar-SA.ts +3 -3
- package/corpus/templates/mail/app/i18n/de-DE.ts +3 -3
- package/corpus/templates/mail/app/i18n/en-US.ts +4 -3
- package/corpus/templates/mail/app/i18n/es-ES.ts +3 -3
- package/corpus/templates/mail/app/i18n/fr-FR.ts +3 -3
- package/corpus/templates/mail/app/i18n/hi-IN.ts +3 -3
- package/corpus/templates/mail/app/i18n/ja-JP.ts +3 -3
- package/corpus/templates/mail/app/i18n/ko-KR.ts +3 -3
- package/corpus/templates/mail/app/i18n/pt-BR.ts +3 -3
- package/corpus/templates/mail/app/i18n/zh-CN.ts +3 -3
- package/corpus/templates/mail/app/i18n/zh-TW.ts +3 -3
- package/corpus/templates/mail/changelog/2026-07-10-slack-intake-marked-legacy.md +6 -0
- package/corpus/templates/mail/server/lib/env-config.ts +5 -4
- package/corpus/templates/plan/package.json +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +20 -0
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/thread-data-builder.d.ts.map +1 -1
- package/dist/agent/thread-data-builder.js +36 -18
- package/dist/agent/thread-data-builder.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +3 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +200 -28
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/chat/tool-call-display.js +1 -1
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/chat/use-reconnect-reader-owner.d.ts +11 -0
- package/dist/client/chat/use-reconnect-reader-owner.d.ts.map +1 -0
- package/dist/client/chat/use-reconnect-reader-owner.js +21 -0
- package/dist/client/chat/use-reconnect-reader-owner.js.map +1 -0
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +5 -1
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.js +5 -7
- package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +67 -20
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/connections/catalog.d.ts +2 -2
- package/dist/connections/catalog.js +2 -2
- package/dist/connections/catalog.js.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +3 -1
- package/dist/deploy/build.js.map +1 -1
- package/dist/integrations/catalog.js +2 -2
- package/dist/integrations/catalog.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.js +1 -1
- package/dist/server/auth.js.map +1 -1
- package/dist/server/credential-provider.d.ts +5 -3
- package/dist/server/credential-provider.d.ts.map +1 -1
- package/dist/server/credential-provider.js +7 -3
- package/dist/server/credential-provider.js.map +1 -1
- package/dist/shared/cache-control.d.ts +8 -6
- package/dist/shared/cache-control.d.ts.map +1 -1
- package/dist/shared/cache-control.js +8 -3
- package/dist/shared/cache-control.js.map +1 -1
- package/docs/content/locales/ar-SA/template-design.mdx +13 -0
- package/docs/content/locales/de-DE/template-design.mdx +18 -0
- package/docs/content/locales/es-ES/template-design.mdx +16 -0
- package/docs/content/locales/fr-FR/template-design.mdx +17 -0
- package/docs/content/locales/hi-IN/template-design.mdx +16 -0
- package/docs/content/locales/ja-JP/template-design.mdx +17 -0
- package/docs/content/locales/ko-KR/template-design.mdx +15 -0
- package/docs/content/locales/pt-BR/template-design.mdx +16 -0
- package/docs/content/locales/zh-CN/template-design.mdx +11 -0
- package/docs/content/locales/zh-TW/template-design.mdx +12 -0
- package/docs/content/template-design.mdx +16 -0
- package/package.json +2 -2
|
@@ -123,6 +123,7 @@ import {
|
|
|
123
123
|
type ApprovalContextValue,
|
|
124
124
|
ReconnectStreamMessage,
|
|
125
125
|
} from "./chat/tool-call-display.js";
|
|
126
|
+
import { useReconnectReaderOwner } from "./chat/use-reconnect-reader-owner.js";
|
|
126
127
|
import {
|
|
127
128
|
MessageScroller,
|
|
128
129
|
MessageScrollerButton,
|
|
@@ -764,6 +765,20 @@ function toolCallIdsRepresentSameLocalCall(
|
|
|
764
765
|
return renderedId === reconnectId || renderedId.endsWith(`:${reconnectId}`);
|
|
765
766
|
}
|
|
766
767
|
|
|
768
|
+
function toolCallIdIsReaderLocal(id: string): boolean {
|
|
769
|
+
return (
|
|
770
|
+
/^tc_\d+$/.test(id) ||
|
|
771
|
+
/:tc_\d+$/.test(id) ||
|
|
772
|
+
id.startsWith("reconnect-activity:")
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function toolCallReaderLocalKey(id: string): string | null {
|
|
777
|
+
const counterMatch = id.match(/(?:^|:)(tc_\d+)$/);
|
|
778
|
+
if (counterMatch?.[1]) return counterMatch[1];
|
|
779
|
+
return id.startsWith("reconnect-activity:") ? id : null;
|
|
780
|
+
}
|
|
781
|
+
|
|
767
782
|
function collectRenderedToolCallStates(messages: readonly unknown[]): {
|
|
768
783
|
byId: Map<string, { rank: number }>;
|
|
769
784
|
latestAssistantByFingerprint: Map<string, { rank: number }>;
|
|
@@ -862,7 +877,14 @@ function latestRenderedAssistantText(messages: readonly unknown[]): string {
|
|
|
862
877
|
function dedupePendingToolCallReplaysWithinContent(
|
|
863
878
|
content: ContentPart[],
|
|
864
879
|
): ContentPart[] {
|
|
865
|
-
const
|
|
880
|
+
const laterStatesByFingerprint = new Map<
|
|
881
|
+
string,
|
|
882
|
+
{
|
|
883
|
+
ids: Set<string>;
|
|
884
|
+
readerLocalKeys: Set<string>;
|
|
885
|
+
hasStableId: boolean;
|
|
886
|
+
}
|
|
887
|
+
>();
|
|
866
888
|
let changed = false;
|
|
867
889
|
const nextReversed: ContentPart[] = [];
|
|
868
890
|
|
|
@@ -870,53 +892,153 @@ function dedupePendingToolCallReplaysWithinContent(
|
|
|
870
892
|
const part = content[i];
|
|
871
893
|
if (!part) continue;
|
|
872
894
|
const fingerprint = toolCallFingerprintFromContentPart(part);
|
|
873
|
-
|
|
874
|
-
fingerprint
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
)
|
|
895
|
+
const laterState = fingerprint
|
|
896
|
+
? laterStatesByFingerprint.get(fingerprint)
|
|
897
|
+
: undefined;
|
|
898
|
+
const id = toolCallIdFromContentPart(part);
|
|
899
|
+
const readerLocalKey = id ? toolCallReaderLocalKey(id) : null;
|
|
900
|
+
const isReaderReplay = Boolean(
|
|
901
|
+
id &&
|
|
902
|
+
laterState &&
|
|
903
|
+
(laterState.ids.has(id) ||
|
|
904
|
+
(readerLocalKey && laterState.readerLocalKeys.has(readerLocalKey)) ||
|
|
905
|
+
(toolCallIdIsReaderLocal(id) && laterState.hasStableId)),
|
|
906
|
+
);
|
|
907
|
+
if (fingerprint && !toolCallPartHasResult(part) && isReaderReplay) {
|
|
878
908
|
changed = true;
|
|
879
909
|
continue;
|
|
880
910
|
}
|
|
881
|
-
if (fingerprint)
|
|
911
|
+
if (fingerprint) {
|
|
912
|
+
const state = laterState ?? {
|
|
913
|
+
ids: new Set(),
|
|
914
|
+
readerLocalKeys: new Set(),
|
|
915
|
+
hasStableId: false,
|
|
916
|
+
};
|
|
917
|
+
if (id) {
|
|
918
|
+
state.ids.add(id);
|
|
919
|
+
const key = toolCallReaderLocalKey(id);
|
|
920
|
+
if (key) state.readerLocalKeys.add(key);
|
|
921
|
+
else state.hasStableId = true;
|
|
922
|
+
}
|
|
923
|
+
laterStatesByFingerprint.set(fingerprint, state);
|
|
924
|
+
}
|
|
882
925
|
nextReversed.push(part);
|
|
883
926
|
}
|
|
884
927
|
|
|
885
928
|
return changed ? nextReversed.reverse() : content;
|
|
886
929
|
}
|
|
887
930
|
|
|
931
|
+
function pendingToolCallCountsByName(
|
|
932
|
+
content: readonly ContentPart[],
|
|
933
|
+
): Map<string, number> {
|
|
934
|
+
const counts = new Map<string, number>();
|
|
935
|
+
for (const part of content) {
|
|
936
|
+
const name = toolCallNameFromContentPart(part);
|
|
937
|
+
if (!name || toolCallProgressRank(part) >= 4) continue;
|
|
938
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
939
|
+
}
|
|
940
|
+
return counts;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Linear-time longest overlap where a suffix of `renderedText` equals a prefix
|
|
945
|
+
* of `reconnectText`. The reconnect overlay is recalculated during render on
|
|
946
|
+
* every stream tick, so a descending slice/endsWith scan becomes quadratic on
|
|
947
|
+
* large repeated output.
|
|
948
|
+
*/
|
|
949
|
+
function longestSuffixPrefixOverlap(
|
|
950
|
+
renderedText: string,
|
|
951
|
+
reconnectText: string,
|
|
952
|
+
): number {
|
|
953
|
+
const maxOverlap = Math.min(renderedText.length, reconnectText.length);
|
|
954
|
+
if (maxOverlap === 0) return 0;
|
|
955
|
+
|
|
956
|
+
const pattern = reconnectText.slice(0, maxOverlap);
|
|
957
|
+
const prefixLengths = new Array<number>(pattern.length).fill(0);
|
|
958
|
+
for (let index = 1; index < pattern.length; index += 1) {
|
|
959
|
+
let matched = prefixLengths[index - 1] ?? 0;
|
|
960
|
+
while (matched > 0 && pattern[index] !== pattern[matched]) {
|
|
961
|
+
matched = prefixLengths[matched - 1] ?? 0;
|
|
962
|
+
}
|
|
963
|
+
if (pattern[index] === pattern[matched]) matched += 1;
|
|
964
|
+
prefixLengths[index] = matched;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
let matched = 0;
|
|
968
|
+
const renderedStart = renderedText.length - maxOverlap;
|
|
969
|
+
for (let index = renderedStart; index < renderedText.length; index += 1) {
|
|
970
|
+
const character = renderedText[index];
|
|
971
|
+
while (matched > 0 && character !== pattern[matched]) {
|
|
972
|
+
matched = prefixLengths[matched - 1] ?? 0;
|
|
973
|
+
}
|
|
974
|
+
if (character === pattern[matched]) matched += 1;
|
|
975
|
+
if (matched === pattern.length && index < renderedText.length - 1) {
|
|
976
|
+
matched = prefixLengths[matched - 1] ?? 0;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return matched;
|
|
980
|
+
}
|
|
981
|
+
|
|
888
982
|
function trimReconnectTextAlreadyRendered(
|
|
889
983
|
content: ContentPart[],
|
|
890
984
|
renderedAssistantText: string,
|
|
985
|
+
options?: { trimTailOverlap?: boolean },
|
|
891
986
|
): ContentPart[] {
|
|
892
987
|
if (!renderedAssistantText) return content;
|
|
893
988
|
|
|
894
|
-
|
|
989
|
+
const reconnectText = assistantTextFromContent(content);
|
|
990
|
+
if (!reconnectText) return content;
|
|
991
|
+
|
|
992
|
+
let overlapLength = 0;
|
|
993
|
+
if (reconnectText.startsWith(renderedAssistantText)) {
|
|
994
|
+
overlapLength = renderedAssistantText.length;
|
|
995
|
+
} else if (renderedAssistantText.startsWith(reconnectText)) {
|
|
996
|
+
overlapLength = reconnectText.length;
|
|
997
|
+
} else if (options?.trimTailOverlap) {
|
|
998
|
+
const tailOverlap = longestSuffixPrefixOverlap(
|
|
999
|
+
renderedAssistantText,
|
|
1000
|
+
reconnectText,
|
|
1001
|
+
);
|
|
1002
|
+
if (tailOverlap > 0) {
|
|
1003
|
+
// Tail reconnects begin after the last remembered event, so their first
|
|
1004
|
+
// text can be the final suffix of a thread snapshot imported in parallel.
|
|
1005
|
+
// For a partial overlap, require a whole word/phrase boundary so a
|
|
1006
|
+
// coincidental shared character at the join cannot eat legitimate text.
|
|
1007
|
+
const renderedBeforeOverlap =
|
|
1008
|
+
renderedAssistantText[renderedAssistantText.length - tailOverlap - 1];
|
|
1009
|
+
const reconnectAfterOverlap = reconnectText[tailOverlap];
|
|
1010
|
+
const isWordCharacter = (value: string | undefined) =>
|
|
1011
|
+
value ? /[\p{L}\p{N}_]/u.test(value) : false;
|
|
1012
|
+
const isWholeBoundary =
|
|
1013
|
+
!isWordCharacter(renderedBeforeOverlap) &&
|
|
1014
|
+
!isWordCharacter(reconnectAfterOverlap);
|
|
1015
|
+
if (tailOverlap === reconnectText.length || isWholeBoundary) {
|
|
1016
|
+
overlapLength = tailOverlap;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (overlapLength === 0) return content;
|
|
1022
|
+
|
|
1023
|
+
let remainingOverlap = overlapLength;
|
|
895
1024
|
let changed = false;
|
|
896
1025
|
const next: ContentPart[] = [];
|
|
897
1026
|
|
|
898
1027
|
for (const part of content) {
|
|
899
|
-
if (part.type !== "text" || remainingOverlap
|
|
1028
|
+
if (part.type !== "text" || remainingOverlap === 0) {
|
|
900
1029
|
next.push(part);
|
|
901
1030
|
continue;
|
|
902
1031
|
}
|
|
903
1032
|
|
|
904
|
-
if (
|
|
905
|
-
remainingOverlap
|
|
906
|
-
changed = true;
|
|
907
|
-
continue;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
if (part.text.startsWith(remainingOverlap)) {
|
|
911
|
-
const text = part.text.slice(remainingOverlap.length);
|
|
912
|
-
remainingOverlap = "";
|
|
1033
|
+
if (part.text.length <= remainingOverlap) {
|
|
1034
|
+
remainingOverlap -= part.text.length;
|
|
913
1035
|
changed = true;
|
|
914
|
-
if (text) next.push({ ...part, text });
|
|
915
1036
|
continue;
|
|
916
1037
|
}
|
|
917
1038
|
|
|
918
|
-
remainingOverlap
|
|
919
|
-
|
|
1039
|
+
next.push({ ...part, text: part.text.slice(remainingOverlap) });
|
|
1040
|
+
remainingOverlap = 0;
|
|
1041
|
+
changed = true;
|
|
920
1042
|
}
|
|
921
1043
|
|
|
922
1044
|
return changed ? next : content;
|
|
@@ -925,10 +1047,14 @@ function trimReconnectTextAlreadyRendered(
|
|
|
925
1047
|
export function dedupeReconnectContentAgainstMessages(
|
|
926
1048
|
content: ContentPart[],
|
|
927
1049
|
messages: readonly unknown[],
|
|
928
|
-
options?: {
|
|
1050
|
+
options?: {
|
|
1051
|
+
suppressToolRepeats?: boolean;
|
|
1052
|
+
trimTailTextOverlap?: boolean;
|
|
1053
|
+
},
|
|
929
1054
|
): ContentPart[] {
|
|
930
1055
|
if (content.length === 0) return content;
|
|
931
1056
|
const snapshotDeduped = dedupePendingToolCallReplaysWithinContent(content);
|
|
1057
|
+
const reconnectPendingCounts = pendingToolCallCountsByName(snapshotDeduped);
|
|
932
1058
|
let changed = snapshotDeduped !== content;
|
|
933
1059
|
if (messages.length === 0) return changed ? snapshotDeduped : content;
|
|
934
1060
|
const { byId, latestAssistantByFingerprint, latestAssistantByName } =
|
|
@@ -1009,6 +1135,31 @@ export function dedupeReconnectContentAgainstMessages(
|
|
|
1009
1135
|
const latestByName = name
|
|
1010
1136
|
? latestAssistantByName.get(name)
|
|
1011
1137
|
: undefined;
|
|
1138
|
+
const reconnectPendingCount = name
|
|
1139
|
+
? (reconnectPendingCounts.get(name) ?? 0)
|
|
1140
|
+
: 0;
|
|
1141
|
+
const hasReaderLocalIdentity = Boolean(
|
|
1142
|
+
(id && toolCallIdIsReaderLocal(id)) ||
|
|
1143
|
+
(latestByName &&
|
|
1144
|
+
Array.from(latestByName.pendingIds).some(
|
|
1145
|
+
toolCallIdIsReaderLocal,
|
|
1146
|
+
)),
|
|
1147
|
+
);
|
|
1148
|
+
if (
|
|
1149
|
+
options?.suppressToolRepeats &&
|
|
1150
|
+
latestByName &&
|
|
1151
|
+
latestByName.pendingIds.size === 1 &&
|
|
1152
|
+
reconnectPendingCount === 1 &&
|
|
1153
|
+
hasReaderLocalIdentity &&
|
|
1154
|
+
latestByName.pendingRank >= reconnectRank
|
|
1155
|
+
) {
|
|
1156
|
+
// During reconnect -> live-adapter handoff both accumulators own
|
|
1157
|
+
// the same run. Their pre-tool-start activity cards can have
|
|
1158
|
+
// unrelated reader-local ids and no args fingerprint, so name +
|
|
1159
|
+
// pending progress is the only stable identity available.
|
|
1160
|
+
changed = true;
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1012
1163
|
const matchesRenderedLocalId =
|
|
1013
1164
|
id && latestByName
|
|
1014
1165
|
? Array.from(latestByName.ids).some((renderedId) =>
|
|
@@ -1037,6 +1188,7 @@ export function dedupeReconnectContentAgainstMessages(
|
|
|
1037
1188
|
const textDeduped = trimReconnectTextAlreadyRendered(
|
|
1038
1189
|
filtered,
|
|
1039
1190
|
renderedAssistantText,
|
|
1191
|
+
{ trimTailOverlap: options?.trimTailTextOverlap },
|
|
1040
1192
|
);
|
|
1041
1193
|
if (textDeduped !== filtered) changed = true;
|
|
1042
1194
|
return changed ? textDeduped : content;
|
|
@@ -1151,19 +1303,40 @@ function contentHasVisibleReasoning(content: unknown): boolean {
|
|
|
1151
1303
|
});
|
|
1152
1304
|
}
|
|
1153
1305
|
|
|
1306
|
+
function contentHasUnresolvedToolCall(
|
|
1307
|
+
content: unknown,
|
|
1308
|
+
toolName?: string | null,
|
|
1309
|
+
): boolean {
|
|
1310
|
+
if (!Array.isArray(content)) return false;
|
|
1311
|
+
return content.some((part) => {
|
|
1312
|
+
if (!part || typeof part !== "object") return false;
|
|
1313
|
+
const candidate = part as {
|
|
1314
|
+
type?: unknown;
|
|
1315
|
+
toolName?: unknown;
|
|
1316
|
+
result?: unknown;
|
|
1317
|
+
};
|
|
1318
|
+
return (
|
|
1319
|
+
candidate.type === "tool-call" &&
|
|
1320
|
+
candidate.result === undefined &&
|
|
1321
|
+
(!toolName || candidate.toolName === toolName)
|
|
1322
|
+
);
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1154
1326
|
export function shouldShowGlobalRunningStatus({
|
|
1155
1327
|
showRunningInUI,
|
|
1156
1328
|
runningActivityLabel,
|
|
1329
|
+
runningActivityTool,
|
|
1157
1330
|
latestMessage,
|
|
1158
1331
|
reconnectContent,
|
|
1159
1332
|
}: {
|
|
1160
1333
|
showRunningInUI: boolean;
|
|
1161
1334
|
runningActivityLabel: string | null | undefined;
|
|
1335
|
+
runningActivityTool?: string | null;
|
|
1162
1336
|
latestMessage: unknown;
|
|
1163
1337
|
reconnectContent: readonly ContentPart[];
|
|
1164
1338
|
}): boolean {
|
|
1165
1339
|
if (!showRunningInUI) return false;
|
|
1166
|
-
if (runningActivityLabel) return true;
|
|
1167
1340
|
|
|
1168
1341
|
const message =
|
|
1169
1342
|
latestMessage && typeof latestMessage === "object"
|
|
@@ -1172,6 +1345,30 @@ export function shouldShowGlobalRunningStatus({
|
|
|
1172
1345
|
const latestMessageHasReasoning =
|
|
1173
1346
|
message?.role === "assistant" &&
|
|
1174
1347
|
contentHasVisibleReasoning(message.content);
|
|
1348
|
+
const latestMessageHasUnresolvedTool =
|
|
1349
|
+
message?.role === "assistant" &&
|
|
1350
|
+
contentHasUnresolvedToolCall(message.content);
|
|
1351
|
+
const matchingActivityToolIsVisible = Boolean(
|
|
1352
|
+
runningActivityTool &&
|
|
1353
|
+
((message?.role === "assistant" &&
|
|
1354
|
+
contentHasUnresolvedToolCall(message.content, runningActivityTool)) ||
|
|
1355
|
+
contentHasUnresolvedToolCall(reconnectContent, runningActivityTool)),
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1358
|
+
// A pending card for the same tool is already the running indicator.
|
|
1359
|
+
// Rendering its global activity label as well shows the logical call twice
|
|
1360
|
+
// (for example, `generate design` plus `Writing generate design...`).
|
|
1361
|
+
if (runningActivityLabel && matchingActivityToolIsVisible) {
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
if (
|
|
1365
|
+
!runningActivityLabel &&
|
|
1366
|
+
(latestMessageHasUnresolvedTool ||
|
|
1367
|
+
contentHasUnresolvedToolCall(reconnectContent))
|
|
1368
|
+
) {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
if (runningActivityLabel) return true;
|
|
1175
1372
|
|
|
1176
1373
|
return (
|
|
1177
1374
|
!latestMessageHasReasoning && !contentHasVisibleReasoning(reconnectContent)
|
|
@@ -2041,6 +2238,10 @@ const AssistantChatInner = forwardRef<
|
|
|
2041
2238
|
const reconnectCanMaterializeRef = useRef(false);
|
|
2042
2239
|
const reconnectAbortRef = useRef<AbortController | null>(null);
|
|
2043
2240
|
const reconnectAutoRecoveryCountRef = useRef(0);
|
|
2241
|
+
const reconnectOwnerMountedRef = useReconnectReaderOwner(
|
|
2242
|
+
reconnectRunIdRef,
|
|
2243
|
+
reconnectAbortRef,
|
|
2244
|
+
);
|
|
2044
2245
|
const [pendingReconnectRecovery, setPendingReconnectRecovery] =
|
|
2045
2246
|
useState<PendingReconnectRecovery | null>(null);
|
|
2046
2247
|
// Nuclear stop: user clicked stop. Clears the stop button/indicator AND
|
|
@@ -2386,6 +2587,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2386
2587
|
const startReconnectToRun = useCallback(
|
|
2387
2588
|
(runInfo: ActiveRunLookup): boolean => {
|
|
2388
2589
|
if (
|
|
2590
|
+
!reconnectOwnerMountedRef.current ||
|
|
2389
2591
|
!threadId ||
|
|
2390
2592
|
!runInfo.runId ||
|
|
2391
2593
|
(runInfo.status !== "running" && !isReplayableTerminalRun(runInfo))
|
|
@@ -2596,6 +2798,12 @@ const AssistantChatInner = forwardRef<
|
|
|
2596
2798
|
rafPending = true;
|
|
2597
2799
|
requestAnimationFrame(() => {
|
|
2598
2800
|
rafPending = false;
|
|
2801
|
+
if (
|
|
2802
|
+
!reconnectOwnerMountedRef.current ||
|
|
2803
|
+
reconnectRunIdRef.current !== runId
|
|
2804
|
+
) {
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2599
2807
|
setReconnectContent(latestSnapshot);
|
|
2600
2808
|
});
|
|
2601
2809
|
};
|
|
@@ -2665,6 +2873,11 @@ const AssistantChatInner = forwardRef<
|
|
|
2665
2873
|
clearInterval(idleCheck);
|
|
2666
2874
|
}
|
|
2667
2875
|
|
|
2876
|
+
// A newer reader, live adapter, stop action, or component unmount took
|
|
2877
|
+
// ownership while this async loop was unwinding. Do not let the stale
|
|
2878
|
+
// generation refresh/import or update reconnect state afterwards.
|
|
2879
|
+
if (reconnectRunIdRef.current !== runId) return;
|
|
2880
|
+
|
|
2668
2881
|
if (noProgressDuringReconnect && reconnectRunIdRef.current === runId) {
|
|
2669
2882
|
const reconnectErrorCode =
|
|
2670
2883
|
reconnectTerminalReason === "run_timeout"
|
|
@@ -3630,6 +3843,7 @@ const AssistantChatInner = forwardRef<
|
|
|
3630
3843
|
const stillNeeded =
|
|
3631
3844
|
dedupeReconnectContentAgainstMessages(reconnectContent, messages, {
|
|
3632
3845
|
suppressToolRepeats: true,
|
|
3846
|
+
trimTailTextOverlap: true,
|
|
3633
3847
|
}).length > 0;
|
|
3634
3848
|
if (!stillNeeded) {
|
|
3635
3849
|
setReconnectContent([]);
|
|
@@ -4236,7 +4450,11 @@ const AssistantChatInner = forwardRef<
|
|
|
4236
4450
|
const visibleReconnectContent = dedupeReconnectContentAgainstMessages(
|
|
4237
4451
|
reconnectContent,
|
|
4238
4452
|
messages,
|
|
4239
|
-
{
|
|
4453
|
+
{
|
|
4454
|
+
suppressToolRepeats: adapterHandoffPending,
|
|
4455
|
+
trimTailTextOverlap:
|
|
4456
|
+
adapterHandoffPending || reconnectTailOnlyRef.current,
|
|
4457
|
+
},
|
|
4240
4458
|
);
|
|
4241
4459
|
const chatScrollResetKey = `${tabId ?? ""}:${threadId ?? ""}`;
|
|
4242
4460
|
|
|
@@ -4262,11 +4480,18 @@ const AssistantChatInner = forwardRef<
|
|
|
4262
4480
|
);
|
|
4263
4481
|
const latestMessage = messages[messages.length - 1];
|
|
4264
4482
|
const latestMessageRole = latestMessage?.role;
|
|
4483
|
+
const reconnectStatusContent =
|
|
4484
|
+
visibleReconnectContent.length > 0
|
|
4485
|
+
? visibleReconnectContent
|
|
4486
|
+
: reconnectContent.length === 0
|
|
4487
|
+
? reconnectActivityContent
|
|
4488
|
+
: [];
|
|
4265
4489
|
const showGlobalRunningStatus = shouldShowGlobalRunningStatus({
|
|
4266
4490
|
showRunningInUI,
|
|
4267
4491
|
runningActivityLabel,
|
|
4492
|
+
runningActivityTool,
|
|
4268
4493
|
latestMessage,
|
|
4269
|
-
reconnectContent:
|
|
4494
|
+
reconnectContent: reconnectStatusContent,
|
|
4270
4495
|
});
|
|
4271
4496
|
const latestAssistantWasPlan =
|
|
4272
4497
|
latestMessageRole === "assistant" &&
|
|
@@ -976,7 +976,7 @@ export function ReasoningCell({
|
|
|
976
976
|
durationMs?: number | null;
|
|
977
977
|
}) {
|
|
978
978
|
const embeddedInWorkSummary = React.useContext(WorkSummaryContentContext);
|
|
979
|
-
const [open, setOpen] = useState(defaultOpen ??
|
|
979
|
+
const [open, setOpen] = useState(defaultOpen ?? true);
|
|
980
980
|
const trimmed = text.trim();
|
|
981
981
|
if (!trimmed && !isStreaming) return null;
|
|
982
982
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type MutableRef<T> = { current: T };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Owns the lifetime of AssistantChat's reconnect reader. The inner chat can be
|
|
7
|
+
* remounted while its outer runtime survives (error-boundary recovery, keyed
|
|
8
|
+
* thread changes), so unmount must explicitly retire the old reader.
|
|
9
|
+
*/
|
|
10
|
+
export function useReconnectReaderOwner(
|
|
11
|
+
reconnectRunIdRef: MutableRef<string | null>,
|
|
12
|
+
reconnectAbortRef: MutableRef<AbortController | null>,
|
|
13
|
+
): MutableRef<boolean> {
|
|
14
|
+
const mountedRef = useRef(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
mountedRef.current = true;
|
|
18
|
+
return () => {
|
|
19
|
+
mountedRef.current = false;
|
|
20
|
+
const reconnectAbort = reconnectAbortRef.current;
|
|
21
|
+
reconnectRunIdRef.current = null;
|
|
22
|
+
reconnectAbortRef.current = null;
|
|
23
|
+
reconnectAbort?.abort();
|
|
24
|
+
};
|
|
25
|
+
}, [reconnectAbortRef, reconnectRunIdRef]);
|
|
26
|
+
|
|
27
|
+
return mountedRef;
|
|
28
|
+
}
|
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
IconUpload,
|
|
5
5
|
IconX,
|
|
6
6
|
} from "@tabler/icons-react";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
keepPreviousData,
|
|
9
|
+
useQuery,
|
|
10
|
+
useQueryClient,
|
|
11
|
+
} from "@tanstack/react-query";
|
|
8
12
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
9
13
|
|
|
10
14
|
import { sendToAgentChat } from "./agent-chat.js";
|
|
@@ -1051,6 +1055,10 @@ export function useGuidedQuestionFlow({
|
|
|
1051
1055
|
},
|
|
1052
1056
|
refetchInterval: resolvedRefetchInterval,
|
|
1053
1057
|
structuralSharing: false,
|
|
1058
|
+
// A matching app-state event changes the query key. Preserve the existing
|
|
1059
|
+
// payload while the replacement read is in flight so full-canvas question
|
|
1060
|
+
// forms do not unmount and flash during routine sync updates.
|
|
1061
|
+
placeholderData: keepPreviousData,
|
|
1054
1062
|
});
|
|
1055
1063
|
|
|
1056
1064
|
useEffect(() => {
|
|
@@ -43,16 +43,15 @@ interface PlatformInfo {
|
|
|
43
43
|
const PLATFORMS: PlatformInfo[] = [
|
|
44
44
|
{
|
|
45
45
|
id: "slack",
|
|
46
|
-
label: "Slack",
|
|
46
|
+
label: "Slack (legacy)",
|
|
47
47
|
icon: IconBrandSlack,
|
|
48
|
-
description:
|
|
48
|
+
description:
|
|
49
|
+
"Legacy single-workspace setup. Use Settings → Messaging for new Slack connections.",
|
|
49
50
|
envVars: ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"],
|
|
50
51
|
setupSteps: [
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"Install the app to your workspace",
|
|
55
|
-
"Copy the Bot Token and Signing Secret into your environment",
|
|
52
|
+
"Open Settings → Messaging for the supported managed Slack setup",
|
|
53
|
+
"Use this legacy setup only for an existing single-workspace installation",
|
|
54
|
+
"Managed OAuth stores workspace bot tokens automatically; do not add SLACK_BOT_TOKEN for new connections",
|
|
56
55
|
],
|
|
57
56
|
docsUrl: "https://api.slack.com/apps",
|
|
58
57
|
},
|
|
@@ -231,12 +231,6 @@ export type PreparingActionState = {
|
|
|
231
231
|
toolEntries?: Map<string, PreparingActionEntry>;
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
-
function formatProgressBytes(bytes: number): string {
|
|
235
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
236
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
237
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
234
|
function activityProgressBytes(ev: SSEEvent): number | undefined {
|
|
241
235
|
return typeof ev.progressBytes === "number" &&
|
|
242
236
|
Number.isFinite(ev.progressBytes) &&
|
|
@@ -281,7 +275,7 @@ function preparationActivityLabel(
|
|
|
281
275
|
if (progressBytes <= 0) {
|
|
282
276
|
return `Preparing ${action}...`;
|
|
283
277
|
}
|
|
284
|
-
return `Writing ${action}
|
|
278
|
+
return `Writing ${action}...`;
|
|
285
279
|
}
|
|
286
280
|
|
|
287
281
|
function visibleActivityLabel(ev: SSEEvent, tool?: string): string {
|
|
@@ -301,16 +295,8 @@ function findPendingToolCallIndex(
|
|
|
301
295
|
// calls can be in flight simultaneously, and name-only matching would
|
|
302
296
|
// attach a result to the wrong call.
|
|
303
297
|
if (toolCallId) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (
|
|
307
|
-
part.type === "tool-call" &&
|
|
308
|
-
part.toolCallId === toolCallId &&
|
|
309
|
-
part.result === undefined
|
|
310
|
-
) {
|
|
311
|
-
return i;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
298
|
+
const exactIndex = findPendingToolCallIndexById(content, toolCallId);
|
|
299
|
+
if (exactIndex >= 0) return exactIndex;
|
|
314
300
|
// Fall through to name-matching: the start event may have arrived before
|
|
315
301
|
// the server started emitting ids (e.g. older server build), so the
|
|
316
302
|
// stored toolCallId is the locally-generated "tc_N" value rather than the
|
|
@@ -329,6 +315,76 @@ function findPendingToolCallIndex(
|
|
|
329
315
|
return -1;
|
|
330
316
|
}
|
|
331
317
|
|
|
318
|
+
function findPendingToolCallIndexById(
|
|
319
|
+
content: ContentPart[],
|
|
320
|
+
toolCallId: string,
|
|
321
|
+
): number {
|
|
322
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
323
|
+
const part = content[i];
|
|
324
|
+
if (
|
|
325
|
+
part.type === "tool-call" &&
|
|
326
|
+
part.toolCallId === toolCallId &&
|
|
327
|
+
part.result === undefined
|
|
328
|
+
) {
|
|
329
|
+
return i;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return -1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function findOldestPendingActivityToolCallIndex(
|
|
336
|
+
content: ContentPart[],
|
|
337
|
+
toolName: string,
|
|
338
|
+
): number {
|
|
339
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
340
|
+
const part = content[i];
|
|
341
|
+
if (
|
|
342
|
+
part.type === "tool-call" &&
|
|
343
|
+
part.toolName === toolName &&
|
|
344
|
+
part.activity === true &&
|
|
345
|
+
part.result === undefined
|
|
346
|
+
) {
|
|
347
|
+
return i;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return -1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function findPendingActivityToolCallIndex(
|
|
354
|
+
content: ContentPart[],
|
|
355
|
+
toolName: string,
|
|
356
|
+
toolCallId?: string,
|
|
357
|
+
): number {
|
|
358
|
+
if (!toolCallId) {
|
|
359
|
+
return findPendingToolCallIndex(content, toolName);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Activity events emitted while the model is assembling tool input already
|
|
363
|
+
// carry the engine's stable call id. Match that id exactly so repeated
|
|
364
|
+
// progress for one call updates one placeholder while parallel same-name
|
|
365
|
+
// calls retain separate cards.
|
|
366
|
+
const exactIndex = findPendingToolCallIndexById(content, toolCallId);
|
|
367
|
+
if (exactIndex >= 0) return exactIndex;
|
|
368
|
+
|
|
369
|
+
// Older activity events may omit the id before a later progress event adds
|
|
370
|
+
// it. Upgrade one unambiguous reader-local placeholder instead of painting a
|
|
371
|
+
// second card for the same logical call.
|
|
372
|
+
const readerLocalCandidates: number[] = [];
|
|
373
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
374
|
+
const part = content[i];
|
|
375
|
+
if (
|
|
376
|
+
part.type === "tool-call" &&
|
|
377
|
+
part.toolName === toolName &&
|
|
378
|
+
part.activity === true &&
|
|
379
|
+
part.result === undefined &&
|
|
380
|
+
/^tc_\d+$/.test(part.toolCallId)
|
|
381
|
+
) {
|
|
382
|
+
readerLocalCandidates.push(i);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return readerLocalCandidates.length === 1 ? readerLocalCandidates[0] : -1;
|
|
386
|
+
}
|
|
387
|
+
|
|
332
388
|
function findCompletedToolCallIndex(
|
|
333
389
|
content: ContentPart[],
|
|
334
390
|
toolCallId?: string,
|
|
@@ -1000,7 +1056,17 @@ export function processEvent(
|
|
|
1000
1056
|
}
|
|
1001
1057
|
if (!tool) return { action: "continue" };
|
|
1002
1058
|
|
|
1003
|
-
const pendingToolCallIndex =
|
|
1059
|
+
const pendingToolCallIndex = findPendingActivityToolCallIndex(
|
|
1060
|
+
content,
|
|
1061
|
+
tool,
|
|
1062
|
+
ev.id,
|
|
1063
|
+
);
|
|
1064
|
+
if (pendingToolCallIndex >= 0 && ev.id) {
|
|
1065
|
+
const pending = content[pendingToolCallIndex];
|
|
1066
|
+
if (pending?.type === "tool-call" && pending.toolCallId !== ev.id) {
|
|
1067
|
+
content[pendingToolCallIndex] = { ...pending, toolCallId: ev.id };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1004
1070
|
if (pendingToolCallIndex === -1) {
|
|
1005
1071
|
// Only surface a placeholder spinner when this tool has no card yet. A
|
|
1006
1072
|
// trailing activity heartbeat that arrives after the matching tool_done
|
|
@@ -1011,12 +1077,13 @@ export function processEvent(
|
|
|
1011
1077
|
(part) =>
|
|
1012
1078
|
part.type === "tool-call" &&
|
|
1013
1079
|
part.toolName === tool &&
|
|
1014
|
-
part.result !== undefined
|
|
1080
|
+
part.result !== undefined &&
|
|
1081
|
+
(!ev.id || part.toolCallId === ev.id),
|
|
1015
1082
|
);
|
|
1016
1083
|
if (!hasCompletedSameTool) {
|
|
1017
1084
|
content.push({
|
|
1018
1085
|
type: "tool-call",
|
|
1019
|
-
toolCallId: `tc_${++toolCallCounter.value}`,
|
|
1086
|
+
toolCallId: ev.id ?? `tc_${++toolCallCounter.value}`,
|
|
1020
1087
|
toolName: tool,
|
|
1021
1088
|
argsText: "",
|
|
1022
1089
|
args: {},
|
|
@@ -1054,7 +1121,9 @@ export function processEvent(
|
|
|
1054
1121
|
}
|
|
1055
1122
|
// Pass the server-assigned id so we upgrade the pending activity card
|
|
1056
1123
|
// using id-match when available (parallel same-name calls stay separate).
|
|
1057
|
-
const pendingToolCallIndex =
|
|
1124
|
+
const pendingToolCallIndex = ev.id
|
|
1125
|
+
? findPendingActivityToolCallIndex(content, tool, ev.id)
|
|
1126
|
+
: findOldestPendingActivityToolCallIndex(content, tool);
|
|
1058
1127
|
const pendingToolCall =
|
|
1059
1128
|
pendingToolCallIndex >= 0 ? content[pendingToolCallIndex] : undefined;
|
|
1060
1129
|
const pendingIsActivityPlaceholder =
|
|
@@ -66,9 +66,9 @@ export const WORKSPACE_CONNECTION_PROVIDERS = [
|
|
|
66
66
|
credentialKeys: [
|
|
67
67
|
{
|
|
68
68
|
key: "SLACK_BOT_TOKEN",
|
|
69
|
-
label: "Slack bot token",
|
|
69
|
+
label: "Slack bot token (legacy)",
|
|
70
70
|
description:
|
|
71
|
-
"
|
|
71
|
+
"Legacy single-workspace fallback. For new messaging automations, connect Slack from Settings → Messaging instead.",
|
|
72
72
|
required: true,
|
|
73
73
|
},
|
|
74
74
|
],
|